|
|
<script> |
|
|
import { |
|
|
Play, |
|
|
Download, |
|
|
Loader2, |
|
|
AlertCircle, |
|
|
ChevronDown, |
|
|
Copy, |
|
|
Share, |
|
|
MoreHorizontal, |
|
|
Shuffle, |
|
|
Pause, |
|
|
X, |
|
|
Code, |
|
|
Layout, |
|
|
} from 'lucide-svelte'; |
|
|
import { onMount } from 'svelte'; |
|
|
import Prism from 'prismjs'; |
|
|
import 'prismjs/components/prism-python'; |
|
|
import 'prismjs/components/prism-bash'; |
|
|
|
|
|
let text = `In a hole in the ground, there lived a hobbit. Not a nasty, dirty, wet hole, filled with the ends of worms and an oozy smell, nor yet a dry, bare, sandy hole with nothing in it to sit down on or to eat: it was a hobbit-hole, and that means comfort.`; |
|
|
let selectedVoice = 'Andrew'; |
|
|
let selectedModel = 'Chatterbox'; |
|
|
let modelDropdownOpen = false; |
|
|
let voiceDropdownOpen = false; |
|
|
let isGenerating = false; |
|
|
let audioUrl = null; |
|
|
let generationTime = 0; |
|
|
let exaggeration = 0.25; |
|
|
let temperature = 0.7; |
|
|
let isPlaying = false; |
|
|
let currentTime = 0; |
|
|
let duration = 0; |
|
|
let audioTitle = ''; |
|
|
let audioElement = null; |
|
|
let sampleAudioElement = null; |
|
|
let playingSampleVoice = null; |
|
|
let showErrorModal = false; |
|
|
let errorMessage = ''; |
|
|
let errorDetails = ''; |
|
|
let historyCount = 0; |
|
|
let userVoices = []; |
|
|
let isLoadingVoices = false; |
|
|
let showLoginPrompt = false; |
|
|
let copyNotification = null; |
|
|
let mode = 'api'; |
|
|
let settingsExpanded = false; |
|
|
|
|
|
|
|
|
let setupCode = ''; |
|
|
let pythonCode = ''; |
|
|
let codeUpdateCounter = 0; |
|
|
|
|
|
const famousBookOpeners = [ |
|
|
'It was the best of times, it was the worst of times. It was the age of wisdom, it was the age of foolishness.', |
|
|
'It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.', |
|
|
'All happy families are alike; each unhappy family is unhappy in its own way.', |
|
|
'In a hole in the ground, there lived a hobbit. Not a nasty, dirty, wet hole, filled with the ends of worms and an oozy smell, nor yet a dry, bare, sandy hole with nothing in it to sit down on or to eat: it was a hobbit-hole, and that means comfort.', |
|
|
]; |
|
|
let currentBookIndex = 0; |
|
|
|
|
|
const models = [ |
|
|
{ id: 'chatterbox', name: 'Chatterbox', badge: 'recommended' }, |
|
|
{ id: 'kokoro', name: 'Kokoro', badge: 'coming soon', disabled: true }, |
|
|
]; |
|
|
|
|
|
const voices = [ |
|
|
{ |
|
|
id: 'andrew', |
|
|
name: 'Andrew', |
|
|
description: 'Older British man who speaks clearly and warmly.', |
|
|
sample: '/voices/andrew.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/andrew.mp3', |
|
|
}, |
|
|
{ |
|
|
id: 'lily', |
|
|
name: 'Lily', |
|
|
description: 'Friendly, conversational tone of a woman in her 30s', |
|
|
sample: '/voices/lily.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/lily.mp3', |
|
|
}, |
|
|
{ |
|
|
id: 'pirate', |
|
|
name: 'Pirate', |
|
|
description: 'Young male pirate-y voice that speaks gruffly and with excitement', |
|
|
sample: '/voices/pirate.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/pirate.mp3', |
|
|
}, |
|
|
{ |
|
|
id: 'fairy', |
|
|
name: 'Fairy', |
|
|
description: 'High and airy female voice that bursts with excitement', |
|
|
sample: '/voices/fairy.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/fairy.mp3', |
|
|
}, |
|
|
]; |
|
|
|
|
|
async function generateSpeech() { |
|
|
if (!text.trim()) return; |
|
|
|
|
|
const response = await fetch('/api/auth/user', { credentials: 'include' }); |
|
|
if (!response.ok) { |
|
|
showLoginPrompt = true; |
|
|
return; |
|
|
} |
|
|
|
|
|
isGenerating = true; |
|
|
audioUrl = null; |
|
|
currentTime = 0; |
|
|
isPlaying = false; |
|
|
audioTitle = text.length > 30 ? text.substring(0, 30) + '...' : text; |
|
|
|
|
|
try { |
|
|
|
|
|
let voiceUrl = null; |
|
|
|
|
|
if (selectedVoice === 'Yours' && userVoices.length > 0) { |
|
|
|
|
|
const userVoice = userVoices[0]; |
|
|
if (userVoice && userVoice.voice_url) { |
|
|
voiceUrl = userVoice.voice_url; |
|
|
} |
|
|
} else { |
|
|
|
|
|
const voice = voices.find((v) => v.name === selectedVoice); |
|
|
if (voice && voice.preview_url) { |
|
|
voiceUrl = voice.preview_url; |
|
|
} |
|
|
} |
|
|
|
|
|
const requestBody = { |
|
|
text: text, |
|
|
voice_id: selectedVoice.toLowerCase(), |
|
|
model_id: selectedModel.toLowerCase(), |
|
|
mode: 'api', |
|
|
parameters: { |
|
|
exaggeration: exaggeration, |
|
|
temperature: temperature, |
|
|
}, |
|
|
voice_url: voiceUrl, |
|
|
}; |
|
|
|
|
|
const response = await fetch('/api/tts/generate', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
credentials: 'include', |
|
|
body: JSON.stringify(requestBody), |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text(); |
|
|
throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`); |
|
|
} |
|
|
|
|
|
const result = await response.json(); |
|
|
|
|
|
if (result.success && result.audio_url) { |
|
|
audioUrl = result.audio_url; |
|
|
generationTime = result.generation_time || 0; |
|
|
|
|
|
|
|
|
await saveToHistory(requestBody, result); |
|
|
|
|
|
setTimeout(() => { |
|
|
if (audioElement) { |
|
|
audioElement.play().catch(() => { |
|
|
|
|
|
}); |
|
|
} |
|
|
}, 100); |
|
|
} else { |
|
|
const errorMessage = result.error || 'Unknown error occurred'; |
|
|
showError('Generation Failed', errorMessage); |
|
|
audioUrl = null; |
|
|
} |
|
|
} catch (error) { |
|
|
showError( |
|
|
'Network Error', |
|
|
'Failed to connect to the server. Please check your connection and try again.' |
|
|
); |
|
|
audioUrl = null; |
|
|
} finally { |
|
|
isGenerating = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function togglePlayPause() { |
|
|
if (audioElement) { |
|
|
if (isPlaying) { |
|
|
audioElement.pause(); |
|
|
} else { |
|
|
audioElement.play(); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function handleAudioLoad() { |
|
|
if (audioElement) { |
|
|
duration = audioElement.duration; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleTimeUpdate() { |
|
|
if (audioElement) { |
|
|
currentTime = audioElement.currentTime; |
|
|
} |
|
|
} |
|
|
|
|
|
function handlePlay() { |
|
|
isPlaying = true; |
|
|
} |
|
|
|
|
|
function handlePause() { |
|
|
isPlaying = false; |
|
|
} |
|
|
|
|
|
function formatTime(seconds) { |
|
|
const mins = Math.floor(seconds / 60); |
|
|
const secs = Math.floor(seconds % 60); |
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`; |
|
|
} |
|
|
|
|
|
function downloadAudio() { |
|
|
if (audioUrl) { |
|
|
const a = document.createElement('a'); |
|
|
a.href = audioUrl; |
|
|
a.download = 'speech.wav'; |
|
|
a.click(); |
|
|
} |
|
|
} |
|
|
|
|
|
function shareAudio() {} |
|
|
|
|
|
function playSampleVoice(voice, event) { |
|
|
event.stopPropagation(); |
|
|
|
|
|
if (playingSampleVoice === voice.name) { |
|
|
if (sampleAudioElement) { |
|
|
sampleAudioElement.pause(); |
|
|
sampleAudioElement.currentTime = 0; |
|
|
} |
|
|
playingSampleVoice = null; |
|
|
} else { |
|
|
if (sampleAudioElement) { |
|
|
sampleAudioElement.pause(); |
|
|
} |
|
|
playingSampleVoice = voice.name; |
|
|
|
|
|
const sampleUrl = voice.sample || '/samples/harvard.wav'; |
|
|
|
|
|
if (!sampleAudioElement) { |
|
|
sampleAudioElement = new Audio(sampleUrl); |
|
|
sampleAudioElement.addEventListener('ended', () => { |
|
|
playingSampleVoice = null; |
|
|
}); |
|
|
} else { |
|
|
sampleAudioElement.src = sampleUrl; |
|
|
} |
|
|
|
|
|
sampleAudioElement.play().catch((err) => { |
|
|
playingSampleVoice = null; |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
function handleKeyDown(event) { |
|
|
if (event.key === 'Enter' && !event.shiftKey) { |
|
|
event.preventDefault(); |
|
|
generateSpeech(); |
|
|
} |
|
|
if (event.key === 'Escape') { |
|
|
modelDropdownOpen = false; |
|
|
voiceDropdownOpen = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleClickOutside(event) { |
|
|
if (!event.target.closest('.model-dropdown')) { |
|
|
modelDropdownOpen = false; |
|
|
} |
|
|
if (!event.target.closest('.voice-dropdown')) { |
|
|
voiceDropdownOpen = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function showError(message, details = '') { |
|
|
errorMessage = message; |
|
|
errorDetails = details; |
|
|
showErrorModal = true; |
|
|
} |
|
|
|
|
|
function closeErrorModal() { |
|
|
showErrorModal = false; |
|
|
errorMessage = ''; |
|
|
errorDetails = ''; |
|
|
} |
|
|
|
|
|
function refreshText() { |
|
|
currentBookIndex = (currentBookIndex + 1) % famousBookOpeners.length; |
|
|
text = famousBookOpeners[currentBookIndex]; |
|
|
} |
|
|
|
|
|
async function saveToHistory(requestBody, result) { |
|
|
try { |
|
|
|
|
|
let voiceUrl = null; |
|
|
|
|
|
|
|
|
const userVoice = userVoices.find((v) => v.voice_name === selectedVoice); |
|
|
if (userVoice && userVoice.voice_url) { |
|
|
voiceUrl = userVoice.voice_url; |
|
|
} else { |
|
|
|
|
|
const builtInVoice = voices.find((v) => v.name === selectedVoice); |
|
|
if (builtInVoice && builtInVoice.preview_url) { |
|
|
voiceUrl = builtInVoice.preview_url; |
|
|
} |
|
|
} |
|
|
|
|
|
const pythonCode = `audio_bytes = client.text_to_speech( |
|
|
"${requestBody.text.replace(/"/g, '\\"')}", |
|
|
extra_body={ |
|
|
"exaggeration": ${requestBody.parameters.exaggeration}, |
|
|
"temperature": ${requestBody.parameters.temperature}${ |
|
|
voiceUrl |
|
|
? `, |
|
|
"audio_url": "${voiceUrl}"` |
|
|
: '' |
|
|
} |
|
|
} |
|
|
)`; |
|
|
|
|
|
await fetch('/api/history/save', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
credentials: 'include', |
|
|
body: JSON.stringify({ |
|
|
code: pythonCode, |
|
|
result_type: 'audio', |
|
|
result_data: { |
|
|
url: result.audio_url, |
|
|
title: audioTitle, |
|
|
type: 'audio', |
|
|
}, |
|
|
entry_type: 'generation', |
|
|
}), |
|
|
}); |
|
|
|
|
|
// Update history count |
|
|
await loadHistoryCount(); |
|
|
} catch (error) { |
|
|
console.error('Error saving to history:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadHistoryCount() { |
|
|
try { |
|
|
const response = await fetch('/api/history/load', { |
|
|
method: 'GET', |
|
|
credentials: 'include', |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
const generationEntries = data.entries.filter((e) => e.entry_type === 'generation'); |
|
|
historyCount = generationEntries.length; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading history count:', error); |
|
|
historyCount = 0; |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadUserVoices() { |
|
|
try { |
|
|
isLoadingVoices = true; |
|
|
const response = await fetch('/api/voice/user-voices', { |
|
|
method: 'GET', |
|
|
credentials: 'include', |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
userVoices = data.voices || []; |
|
|
} else { |
|
|
userVoices = []; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading user voices:', error); |
|
|
userVoices = []; |
|
|
} finally { |
|
|
isLoadingVoices = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleAuthAction() { |
|
|
// Get OAuth config and redirect to HuggingFace OAuth |
|
|
const clientId = '4831a493-1dbc-4dd4-9bb3-c3b41d2e96ba'; |
|
|
const scopes = 'inference-api manage-repos'; |
|
|
|
|
|
// Store current path to return to after auth |
|
|
const returnPath = window.location.pathname; |
|
|
|
|
|
// Determine the correct callback URL based on environment |
|
|
let redirectUri; |
|
|
if (window.location.hostname === 'localhost') { |
|
|
// Development: use backend port for callback |
|
|
redirectUri = 'http://localhost:7860/auth/callback'; |
|
|
} else { |
|
|
// Production: use current origin |
|
|
redirectUri = `${window.location.origin}/auth/callback`; |
|
|
} |
|
|
|
|
|
const authUrl = `https://huggingface.co/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scopes)}&response_type=code&state=${encodeURIComponent(returnPath)}`; |
|
|
|
|
|
window.location.href = authUrl; |
|
|
} |
|
|
|
|
|
function generateSetupCode() { |
|
|
if (mode === 'local') { |
|
|
return `pip install huggingface-hub hfstudio uv |
|
|
hfstudio start ${selectedModel.toLowerCase()} --port 7861`; |
|
|
} else { |
|
|
return `pip install huggingface-hub`; |
|
|
} |
|
|
} |
|
|
|
|
|
function generateClientInitCode() { |
|
|
if (mode === 'local') { |
|
|
const port = 7861; |
|
|
return `client = InferenceClient(base_url="http: |
|
|
} else { |
|
|
const endpointModel = |
|
|
selectedModel.toLowerCase() === 'chatterbox' |
|
|
? 'ResembleAI/chatterbox' |
|
|
: selectedModel.toLowerCase(); |
|
|
return `client = InferenceClient( |
|
|
api_key="YOUR_HF_TOKEN", # Get your token from https: |
|
|
model="${endpointModel}", |
|
|
)`; |
|
|
} |
|
|
} |
|
|
|
|
|
function generateImportCode() { |
|
|
const clientCode = generateClientInitCode(); |
|
|
|
|
|
if (mode === 'local') { |
|
|
return `from huggingface_hub import InferenceClient |
|
|
|
|
|
${clientCode}`; |
|
|
} else { |
|
|
return `from huggingface_hub import InferenceClient |
|
|
|
|
|
${clientCode}`; |
|
|
} |
|
|
} |
|
|
|
|
|
function generatePythonCode() { |
|
|
|
|
|
let voiceUrl = null; |
|
|
|
|
|
|
|
|
if (selectedVoice === 'Yours' && userVoices.length > 0) { |
|
|
const userVoice = userVoices[0]; |
|
|
if (userVoice && userVoice.voice_url) { |
|
|
voiceUrl = userVoice.voice_url; |
|
|
} |
|
|
} else { |
|
|
|
|
|
const builtInVoice = voices.find((v) => v.name === selectedVoice); |
|
|
if (builtInVoice && builtInVoice.preview_url) { |
|
|
voiceUrl = builtInVoice.preview_url; |
|
|
} |
|
|
} |
|
|
|
|
|
const currentText = text || 'Hello, this is a sample text.'; |
|
|
|
|
|
|
|
|
const clientCode = generateClientInitCode(); |
|
|
const imports = `from huggingface_hub import InferenceClient |
|
|
|
|
|
${clientCode}`; |
|
|
|
|
|
return `${imports} |
|
|
|
|
|
audio_bytes = client.text_to_speech( |
|
|
"${currentText.replace(/"/g, '\\"')}", |
|
|
extra_body={ |
|
|
"exaggeration": ${exaggeration}, |
|
|
"temperature": ${temperature}${ |
|
|
voiceUrl |
|
|
? `, |
|
|
"audio_url": "${voiceUrl}"` |
|
|
: '' |
|
|
} |
|
|
} |
|
|
)`; |
|
|
} |
|
|
|
|
|
function copyToClipboard(textToCopy, message = 'Copied to clipboard!') { |
|
|
navigator.clipboard.writeText(textToCopy).then(() => { |
|
|
copyNotification = message; |
|
|
setTimeout(() => { |
|
|
copyNotification = null; |
|
|
}, 2000); |
|
|
}); |
|
|
} |
|
|
|
|
|
function deployAsSpace() { |
|
|
// Get voice URL for the current selection |
|
|
let voiceUrl = null; |
|
|
if (selectedVoice === 'Yours' && userVoices.length > 0) { |
|
|
const userVoice = userVoices[0]; |
|
|
if (userVoice && userVoice.voice_url) { |
|
|
voiceUrl = userVoice.voice_url; // Use external URL directly |
|
|
} |
|
|
} else { |
|
|
const voice = voices.find((v) => v.name === selectedVoice); |
|
|
if (voice && voice.preview_url) { |
|
|
voiceUrl = voice.preview_url; |
|
|
} |
|
|
} |
|
|
|
|
|
// Generate the app.py content |
|
|
const appCode = `from huggingface_hub import InferenceClient |
|
|
import gradio as gr |
|
|
import os |
|
|
|
|
|
client = InferenceClient(api_key=os.getenv("HF_TOKEN")) |
|
|
|
|
|
def generate_speech(text): |
|
|
if not text.strip(): |
|
|
return None |
|
|
|
|
|
try: |
|
|
audio_bytes = client.text_to_speech( |
|
|
text, |
|
|
extra_body={ |
|
|
"exaggeration": ${exaggeration}, |
|
|
"temperature": ${temperature}, |
|
|
${voiceUrl ? `"audio_url": "${voiceUrl}",` : ''} |
|
|
} |
|
|
) |
|
|
return audio_bytes |
|
|
except Exception as e: |
|
|
raise gr.Error(f"Error generating speech: {str(e)}") |
|
|
|
|
|
# Create the Gradio interface |
|
|
with gr.Blocks(title="Text to Speech") as demo: |
|
|
gr.Markdown("# Text to Speech") |
|
|
gr.Markdown("Convert text to speech using the Chatterbox model.") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(): |
|
|
text_input = gr.Textbox( |
|
|
label="Text to convert to speech", |
|
|
placeholder="${text.replace(/"/g, '\\"').substring(0, 100)}...", |
|
|
lines=5, |
|
|
value="${text.replace(/"/g, '\\"')}" |
|
|
) |
|
|
generate_btn = gr.Button("Generate Speech", variant="primary") |
|
|
|
|
|
with gr.Column(): |
|
|
audio_output = gr.Audio(label="Generated Speech") |
|
|
|
|
|
generate_btn.click( |
|
|
fn=generate_speech, |
|
|
inputs=[text_input], |
|
|
outputs=[audio_output] |
|
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.launch()`; |
|
|
|
|
|
const requirementsContent = `huggingface_hub |
|
|
gradio`; |
|
|
|
|
|
|
|
|
const baseUrl = 'https://huggingface.co/new-space'; |
|
|
const params = new URLSearchParams({ |
|
|
name: `text-to-speech-${selectedVoice.toLowerCase()}-${Date.now()}`, |
|
|
sdk: 'gradio', |
|
|
'files[0][path]': 'app.py', |
|
|
'files[0][content]': appCode, |
|
|
'files[1][path]': 'requirements.txt', |
|
|
'files[1][content]': requirementsContent, |
|
|
}); |
|
|
|
|
|
window.open(`${baseUrl}?${params.toString()}`, '_blank'); |
|
|
} |
|
|
|
|
|
function copyAllCode() { |
|
|
const parts = []; |
|
|
|
|
|
if (setupCode) { |
|
|
const isTerminalCommand = |
|
|
setupCode.includes('pip install') || setupCode.includes('hfstudio start'); |
|
|
const language = isTerminalCommand ? 'bash' : ''; |
|
|
parts.push(`## Setup (Run in Terminal)\n\n\`\`\`${language}\n${setupCode}\n\`\`\``); |
|
|
} |
|
|
|
|
|
if (pythonCode) { |
|
|
parts.push(`## Python Code\n\n\`\`\`python\n${pythonCode}\n\`\`\``); |
|
|
} |
|
|
|
|
|
const markdownContent = parts.join('\n\n'); |
|
|
copyToClipboard(markdownContent, 'All code copied as Markdown!'); |
|
|
} |
|
|
|
|
|
|
|
|
$: { |
|
|
|
|
|
(text, selectedVoice, selectedModel, exaggeration, temperature, userVoices, mode); |
|
|
setupCode = generateSetupCode(); |
|
|
pythonCode = generatePythonCode(); |
|
|
codeUpdateCounter++; |
|
|
} |
|
|
|
|
|
onMount(async () => { |
|
|
await loadHistoryCount(); |
|
|
await loadUserVoices(); |
|
|
}); |
|
|
</script> |
|
|
|
|
|
<svelte:head> |
|
|
<title>Text to Speech - HFStudio</title> |
|
|
</svelte:head> |
|
|
|
|
|
<div |
|
|
class="flex flex-col h-full" |
|
|
on:click={handleClickOutside} |
|
|
on:keydown={handleKeyDown} |
|
|
role="main" |
|
|
tabindex="-1" |
|
|
> |
|
|
<!-- Main content area --> |
|
|
<div class="flex-1 flex"> |
|
|
<!-- Main content area --> |
|
|
<div class="flex-1 flex flex-col p-6"> |
|
|
<!-- Text input area --> |
|
|
<div class="relative mb-4"> |
|
|
<div class="absolute top-3 left-3 flex items-center gap-2 z-10"> |
|
|
<span class="text-sm text-gray-400">Text to speak</span> |
|
|
<button |
|
|
on:click={refreshText} |
|
|
class="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" |
|
|
title="Refresh with famous book opening" |
|
|
> |
|
|
<Shuffle size={16} /> |
|
|
</button> |
|
|
</div> |
|
|
<div class="absolute top-3 right-3 flex items-center gap-2 z-10"> |
|
|
<span class="text-sm text-gray-400"> |
|
|
{text.length.toLocaleString()} / 1,000 characters |
|
|
</span> |
|
|
</div> |
|
|
<div class="relative"> |
|
|
<textarea |
|
|
bind:value={text} |
|
|
maxlength="1000" |
|
|
class="w-full h-96 pt-10 px-6 pb-16 bg-white resize-none border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent text-gray-900 text-lg leading-relaxed" |
|
|
placeholder="Type the text you'd like to convert to spoken audio here..." |
|
|
on:keydown={handleKeyDown} |
|
|
autofocus |
|
|
/> |
|
|
<!-- Generate button embedded in textarea --> |
|
|
<button |
|
|
on:click={generateSpeech} |
|
|
disabled={isGenerating || !text.trim()} |
|
|
class="absolute bottom-4 right-4 px-5 py-2.5 bg-gradient-to-r from-amber-400 to-orange-500 text-white rounded-lg font-medium hover:from-amber-500 hover:to-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1.5 shadow-sm text-base" |
|
|
> |
|
|
{#if isGenerating} |
|
|
<Loader2 size={16} class="animate-spin" /> |
|
|
Generating... |
|
|
{:else} |
|
|
<Play size={16} /> |
|
|
Generate speech |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Settings panel --> |
|
|
<div class="p-4 border border-gray-200 rounded-lg bg-white mb-6"> |
|
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_1.4fr_1fr] gap-6"> |
|
|
<!-- Model selector --> |
|
|
<div class="model-dropdown"> |
|
|
<h3 class="text-sm font-medium text-gray-900 mb-2">Model</h3> |
|
|
<div class="relative"> |
|
|
<button |
|
|
on:click={() => (modelDropdownOpen = !modelDropdownOpen)} |
|
|
class="w-full p-3 border bg-white text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent shadow-sm text-left flex items-center justify-between {modelDropdownOpen |
|
|
? 'rounded-t-lg border-b-0 border-black' |
|
|
: 'rounded-lg border-black'}" |
|
|
> |
|
|
<span> |
|
|
{#each models as model} |
|
|
{#if model.name === selectedModel} |
|
|
{model.name}{#if model.badge} <span class="text-sm text-gray-500" |
|
|
>({model.badge})</span |
|
|
>{/if} |
|
|
{/if} |
|
|
{/each} |
|
|
</span> |
|
|
<ChevronDown |
|
|
size={14} |
|
|
class="text-gray-500 transition-transform {modelDropdownOpen ? 'rotate-180' : ''}" |
|
|
/> |
|
|
</button> |
|
|
|
|
|
{#if modelDropdownOpen} |
|
|
<div |
|
|
class="absolute top-full left-0 right-0 border border-gray-200 border-t-0 bg-white shadow-lg rounded-b-lg overflow-hidden z-20" |
|
|
> |
|
|
{#each models as model} |
|
|
<button |
|
|
class="w-full px-3 py-2.5 text-left transition-colors text-sm {model.disabled |
|
|
? 'opacity-50 cursor-not-allowed' |
|
|
: 'hover:bg-gray-50'} {model.name === selectedModel ? 'bg-gray-100' : ''}" |
|
|
disabled={model.disabled} |
|
|
on:click={() => { |
|
|
if (!model.disabled) { |
|
|
selectedModel = model.name; |
|
|
modelDropdownOpen = false; |
|
|
} |
|
|
}} |
|
|
> |
|
|
{model.name}{#if model.badge} <span class="text-sm text-gray-500" |
|
|
>({model.badge})</span |
|
|
>{/if} |
|
|
</button> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<!-- Pricing info --> |
|
|
<div class="mt-1.5 text-xs text-gray-500 text-right"> |
|
|
~$0.025 per generation • <a |
|
|
href="https://huggingface.co/settings/billing" |
|
|
target="_blank" |
|
|
class="text-amber-600 hover:text-amber-700 underline">Billing ⤴</a |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Voice selector --> |
|
|
<div> |
|
|
<h3 class="text-sm font-medium text-gray-900 mb-2">Voice</h3> |
|
|
<div class="grid grid-cols-2 gap-2"> |
|
|
<!-- Andrew --> |
|
|
<button |
|
|
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice === |
|
|
'Andrew' |
|
|
? 'border-black' |
|
|
: 'border-gray-200'}" |
|
|
on:click={() => (selectedVoice = 'Andrew')} |
|
|
> |
|
|
<div class="flex items-center justify-between mb-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="w-6 h-6 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold" |
|
|
> |
|
|
A |
|
|
</div> |
|
|
<span class="text-sm font-medium">Andrew</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={(e) => |
|
|
playSampleVoice({ name: 'Andrew', sample: '/voices/andrew.mp3' }, e)} |
|
|
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center" |
|
|
title="Play sample" |
|
|
> |
|
|
{#if playingSampleVoice === 'Andrew'} |
|
|
<Pause size={10} class="text-gray-600" /> |
|
|
{:else} |
|
|
<Play size={10} class="text-gray-600" /> |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
</button> |
|
|
|
|
|
<!-- Lily --> |
|
|
<button |
|
|
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice === |
|
|
'Lily' |
|
|
? 'border-black' |
|
|
: 'border-gray-200'}" |
|
|
on:click={() => (selectedVoice = 'Lily')} |
|
|
> |
|
|
<div class="flex items-center justify-between mb-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="w-6 h-6 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold" |
|
|
> |
|
|
L |
|
|
</div> |
|
|
<span class="text-sm font-medium">Lily</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={(e) => |
|
|
playSampleVoice({ name: 'Lily', sample: '/voices/lily.mp3' }, e)} |
|
|
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center" |
|
|
title="Play sample" |
|
|
> |
|
|
{#if playingSampleVoice === 'Lily'} |
|
|
<Pause size={10} class="text-gray-600" /> |
|
|
{:else} |
|
|
<Play size={10} class="text-gray-600" /> |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
</button> |
|
|
|
|
|
<!-- Pirate --> |
|
|
<button |
|
|
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice === |
|
|
'Pirate' |
|
|
? 'border-black' |
|
|
: 'border-gray-200'}" |
|
|
on:click={() => (selectedVoice = 'Pirate')} |
|
|
> |
|
|
<div class="flex items-center justify-between mb-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="w-6 h-6 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold" |
|
|
> |
|
|
P |
|
|
</div> |
|
|
<span class="text-sm font-medium">Pirate</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={(e) => |
|
|
playSampleVoice({ name: 'Pirate', sample: '/voices/pirate.mp3' }, e)} |
|
|
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center" |
|
|
title="Play sample" |
|
|
> |
|
|
{#if playingSampleVoice === 'Pirate'} |
|
|
<Pause size={10} class="text-gray-600" /> |
|
|
{:else} |
|
|
<Play size={10} class="text-gray-600" /> |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
</button> |
|
|
|
|
|
<!-- User Voice or Clone CTA --> |
|
|
{#if userVoices.length > 0} |
|
|
<button |
|
|
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice === |
|
|
'Yours' |
|
|
? 'border-purple-400 bg-purple-50' |
|
|
: 'border-gray-200'}" |
|
|
on:click={() => (selectedVoice = 'Yours')} |
|
|
> |
|
|
<div class="flex items-center justify-between mb-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="w-6 h-6 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full flex items-center justify-center text-white text-xs" |
|
|
> |
|
|
🎤 |
|
|
</div> |
|
|
<span class="text-xs font-medium">Your cloned voice</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={(e) => |
|
|
playSampleVoice({ name: 'Yours', sample: userVoices[0].voice_url }, e)} |
|
|
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center" |
|
|
title="Play sample" |
|
|
> |
|
|
{#if playingSampleVoice === 'Yours'} |
|
|
<Pause size={10} class="text-gray-600" /> |
|
|
{:else} |
|
|
<Play size={10} class="text-gray-600" /> |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
</button> |
|
|
{:else} |
|
|
<!-- Clone CTA --> |
|
|
<a |
|
|
href="/voice-cloning" |
|
|
class="p-3 border border-purple-200 rounded-lg transition-colors text-left hover:bg-purple-50 bg-purple-25" |
|
|
> |
|
|
<div class="flex items-center justify-between mb-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="w-6 h-6 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full flex items-center justify-center text-white text-xs font-semibold" |
|
|
> |
|
|
🎤 |
|
|
</div> |
|
|
<span class="text-xs font-medium text-purple-900">Clone your voice</span> |
|
|
</div> |
|
|
<div class="w-5 h-5 flex items-center justify-center"> |
|
|
<svg |
|
|
class="w-3 h-3 text-purple-600" |
|
|
fill="none" |
|
|
viewBox="0 0 24 24" |
|
|
stroke="currentColor" |
|
|
> |
|
|
<path |
|
|
stroke-linecap="round" |
|
|
stroke-linejoin="round" |
|
|
stroke-width="2" |
|
|
d="M9 5l7 7-7 7" |
|
|
/> |
|
|
</svg> |
|
|
</div> |
|
|
</div> |
|
|
</a> |
|
|
{/if} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Settings controls --> |
|
|
<div class="space-y-3"> |
|
|
<!-- Exaggeration control --> |
|
|
<div> |
|
|
<div class="mb-1"> |
|
|
<label for="exaggeration-slider" class="text-sm font-medium text-gray-900" |
|
|
>Exaggeration</label |
|
|
> |
|
|
</div> |
|
|
<input |
|
|
id="exaggeration-slider" |
|
|
type="range" |
|
|
bind:value={exaggeration} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf" |
|
|
/> |
|
|
<div class="flex justify-between text-xs text-gray-400 mt-1"> |
|
|
<span>None</span> |
|
|
<span>More</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Stability control --> |
|
|
<div> |
|
|
<div class="mb-1"> |
|
|
<label for="temperature-slider" class="text-sm font-medium text-gray-900" |
|
|
>Stability</label |
|
|
> |
|
|
</div> |
|
|
<input |
|
|
id="temperature-slider" |
|
|
type="range" |
|
|
bind:value={temperature} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf" |
|
|
/> |
|
|
<div class="flex justify-between text-xs text-gray-400 mt-1"> |
|
|
<span>More stable</span> |
|
|
<span>More variable</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Right sidebar - Live Code Display --> |
|
|
<div class="w-96 border-l border-gray-200 bg-white h-full overflow-hidden"> |
|
|
<div class="p-4 h-full overflow-y-auto"> |
|
|
<!-- Login prompt if not authenticated --> |
|
|
{#if showLoginPrompt} |
|
|
<div |
|
|
class="mb-4 px-3 py-2 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg border border-amber-200 relative" |
|
|
> |
|
|
<!-- Close button --> |
|
|
<button |
|
|
on:click={() => (showLoginPrompt = false)} |
|
|
class="absolute top-2 right-2 text-gray-400 hover:text-gray-600 transition-colors" |
|
|
aria-label="Dismiss" |
|
|
> |
|
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|
|
<path |
|
|
stroke-linecap="round" |
|
|
stroke-linejoin="round" |
|
|
stroke-width="2" |
|
|
d="M6 18L18 6M6 6l12 12" |
|
|
/> |
|
|
</svg> |
|
|
</button> |
|
|
|
|
|
<p class="text-sm font-medium text-gray-700 mb-1 pr-4"> |
|
|
Hugging Face <span |
|
|
class="bg-gradient-to-r from-purple-500 via-pink-500 via-green-500 to-blue-500 bg-clip-text text-transparent font-bold" |
|
|
>PRO</span |
|
|
> |
|
|
</p> |
|
|
<p class="text-sm text-gray-600 pr-4"> |
|
|
Sign in to with your Hugging Face <a |
|
|
href="https://huggingface.co/pro" |
|
|
target="_blank" |
|
|
class="text-amber-600 hover:text-amber-700 underline font-medium">PRO account</a |
|
|
> to get started with $2 of free API credits per month. You can add a billing method for |
|
|
additional pay-as-you-go usage ⤴ |
|
|
</p> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<!-- Header --> |
|
|
<div class="mb-4"> |
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-1">Results & Live Documentation</h3> |
|
|
<p class="text-sm text-gray-600">The code below will update as you adjust the UI ✨</p> |
|
|
</div> |
|
|
|
|
|
<!-- Toggle and Copy All button row --> |
|
|
<div class="flex items-center justify-between mb-4"> |
|
|
<!-- API/Local Mode Toggle --> |
|
|
<div class="flex items-center bg-gray-100 rounded-md p-0.5"> |
|
|
<button |
|
|
class="px-2 py-1 text-xs font-medium rounded transition-colors {mode === 'api' |
|
|
? 'bg-white shadow-sm' |
|
|
: 'text-gray-600'}" |
|
|
on:click={() => (mode = 'api')} |
|
|
> |
|
|
API |
|
|
</button> |
|
|
<button |
|
|
class="px-2 py-1 text-xs font-medium rounded transition-colors {mode === 'local' |
|
|
? 'bg-white shadow-sm' |
|
|
: 'text-gray-600'}" |
|
|
on:click={() => (mode = 'local')} |
|
|
> |
|
|
Local |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div class="flex items-center gap-2"> |
|
|
<button |
|
|
on:click={copyAllCode} |
|
|
class="flex items-center bg-gray-100 hover:bg-gray-200 rounded-md px-2 py-1 transition-colors" |
|
|
> |
|
|
<Copy size={12} class="text-gray-600" /> |
|
|
<span class="ml-1 text-xs font-medium text-gray-600">Copy all</span> |
|
|
</button> |
|
|
|
|
|
<button |
|
|
on:click={deployAsSpace} |
|
|
class="flex items-center bg-amber-100 hover:bg-amber-200 rounded-md px-2 py-1 transition-colors" |
|
|
> |
|
|
<svg |
|
|
width="12" |
|
|
height="12" |
|
|
viewBox="0 0 24 24" |
|
|
fill="none" |
|
|
stroke="currentColor" |
|
|
stroke-width="2" |
|
|
class="text-amber-700" |
|
|
> |
|
|
<path d="M12 2L2 7l10 5 10-5-10-5z" /> |
|
|
<path d="M2 17l10 5 10-5" /> |
|
|
<path d="M2 12l10 5 10-5" /> |
|
|
</svg> |
|
|
<span class="ml-1 text-xs font-medium text-amber-700">Deploy as Space</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Code sections --> |
|
|
<div class="space-y-4"> |
|
|
<!-- Setup Section --> |
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> |
|
|
<div |
|
|
class="flex items-center justify-between px-3 py-2 bg-blue-50 border-b border-blue-200" |
|
|
> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span class="text-xs font-medium text-blue-900">Install in Terminal</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={() => copyToClipboard(setupCode)} |
|
|
class="p-1 hover:bg-blue-100 rounded transition-colors" |
|
|
title="Copy setup code" |
|
|
> |
|
|
<Copy size={12} class="text-blue-600" /> |
|
|
</button> |
|
|
</div> |
|
|
<div class="relative"> |
|
|
{#key codeUpdateCounter} |
|
|
<pre class="p-3 overflow-x-auto bg-gray-50 text-xs"><code class="language-bash" |
|
|
>{@html Prism.highlight(setupCode, Prism.languages.bash, 'bash')}</code |
|
|
></pre> |
|
|
{/key} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Python Code Section (Imports + Generation) --> |
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> |
|
|
<div |
|
|
class="flex items-center justify-between px-3 py-2 bg-amber-50 border-b border-amber-200" |
|
|
> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span class="text-xs font-medium text-amber-900">Python Code</span> |
|
|
<span class="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">Live</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={() => copyToClipboard(pythonCode)} |
|
|
class="p-1 hover:bg-amber-100 rounded transition-colors" |
|
|
title="Copy Python code" |
|
|
> |
|
|
<Copy size={12} class="text-amber-600" /> |
|
|
</button> |
|
|
</div> |
|
|
<div class="relative"> |
|
|
{#key codeUpdateCounter} |
|
|
<pre class="p-3 overflow-x-auto bg-gray-50 text-xs"><code class="language-python" |
|
|
>{@html Prism.highlight(pythonCode, Prism.languages.python, 'python')}</code |
|
|
></pre> |
|
|
{/key} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Generated Audio Section --> |
|
|
{#if audioUrl} |
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mt-4"> |
|
|
<div |
|
|
class="flex items-center justify-between px-3 py-2 bg-green-50 border-b border-green-200" |
|
|
> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="w-1.5 h-1.5 bg-green-500 rounded-full"></div> |
|
|
<span class="text-xs font-medium text-green-900">Generated Audio</span> |
|
|
{#if generationTime > 0 && mode !== 'local'} |
|
|
<span class="text-xs text-green-700">(took {generationTime.toFixed(1)}s)</span> |
|
|
{/if} |
|
|
</div> |
|
|
<div class="flex items-center gap-1"> |
|
|
<button |
|
|
on:click={downloadAudio} |
|
|
class="flex items-center gap-1 px-2 py-1 hover:bg-green-100 rounded transition-colors" |
|
|
title="Download audio" |
|
|
> |
|
|
<span class="text-xs text-green-700">Download</span> |
|
|
<Download size={12} class="text-green-600" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="p-3"> |
|
|
<!-- Audio info --> |
|
|
<div class="mb-3"> |
|
|
<h4 class="font-medium text-gray-900 text-xs">{audioTitle}</h4> |
|
|
<p class="text-xs text-gray-500">{selectedVoice} • {formatTime(duration)}</p> |
|
|
</div> |
|
|
|
|
|
<!-- Audio controls --> |
|
|
<div class="flex items-center gap-2"> |
|
|
<!-- Play/Pause button --> |
|
|
<button |
|
|
on:click={togglePlayPause} |
|
|
class="w-6 h-6 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors flex-shrink-0" |
|
|
> |
|
|
{#if isPlaying} |
|
|
<div class="pause-filled text-white text-xs"></div> |
|
|
{:else} |
|
|
<Play size={10} class="text-white ml-0.5" /> |
|
|
{/if} |
|
|
</button> |
|
|
|
|
|
<!-- Progress bar --> |
|
|
<div class="flex-1 flex items-center gap-1"> |
|
|
<span class="text-xs text-gray-500 font-mono">{formatTime(currentTime)}</span> |
|
|
<div class="flex-1 h-1 bg-gray-200 rounded-full cursor-pointer"> |
|
|
<div |
|
|
class="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all" |
|
|
style="width: {(currentTime / duration) * 100}%" |
|
|
></div> |
|
|
</div> |
|
|
<span class="text-xs text-gray-500 font-mono">{formatTime(duration)}</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Hidden audio element --> |
|
|
{#if audioUrl} |
|
|
<audio |
|
|
bind:this={audioElement} |
|
|
src={audioUrl} |
|
|
on:loadedmetadata={handleAudioLoad} |
|
|
on:timeupdate={handleTimeUpdate} |
|
|
on:play={handlePlay} |
|
|
on:pause={handlePause} |
|
|
style="display: none;" |
|
|
/> |
|
|
{/if} |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Error Modal --> |
|
|
{#if showErrorModal} |
|
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> |
|
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] flex flex-col"> |
|
|
<!-- Header --> |
|
|
<div |
|
|
class="flex items-center justify-between p-6 border-b border-gray-200 bg-red-50 flex-shrink-0" |
|
|
> |
|
|
<div class="flex items-center gap-3 min-w-0"> |
|
|
<div |
|
|
class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0" |
|
|
> |
|
|
<AlertCircle size={20} class="text-red-600" /> |
|
|
</div> |
|
|
<div class="min-w-0"> |
|
|
<h3 class="text-lg font-semibold text-gray-900 truncate">{errorMessage}</h3> |
|
|
<p class="text-sm text-gray-600">An error occurred while processing your request</p> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
on:click={closeErrorModal} |
|
|
class="p-2 hover:bg-red-100 rounded-full transition-colors flex-shrink-0" |
|
|
title="Close" |
|
|
> |
|
|
<X size={20} class="text-gray-500" /> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<!-- Content --> |
|
|
<div class="p-6 overflow-y-auto flex-1 min-h-0"> |
|
|
{#if errorDetails} |
|
|
<div class="bg-gray-50 rounded-lg p-4 border"> |
|
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Error Details:</h4> |
|
|
<pre |
|
|
class="text-xs text-gray-700 whitespace-pre-wrap font-mono leading-relaxed break-words">{#if errorDetails.includes('exceeded your monthly included credits')}{@html errorDetails.replace( |
|
|
'Subscribe to PRO', |
|
|
'<a href="https://huggingface.co/settings/billing" target="_blank" class="text-amber-600 hover:text-amber-700 underline font-medium">Subscribe to PRO</a>' |
|
|
)}{:else}{errorDetails}{/if}</pre> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<!-- Footer --> |
|
|
<div |
|
|
class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50 flex-shrink-0" |
|
|
> |
|
|
<button |
|
|
on:click={closeErrorModal} |
|
|
class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" |
|
|
> |
|
|
Close |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<!-- Copy notification toast --> |
|
|
{#if copyNotification} |
|
|
<div |
|
|
class="fixed bottom-4 right-4 px-4 py-2 bg-gray-900 text-white rounded-lg shadow-lg z-50 animate-fade-in" |
|
|
> |
|
|
{copyNotification} |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<style> |
|
|
@keyframes fade-in { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(10px); |
|
|
} |
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
.animate-fade-in { |
|
|
animation: fade-in 0.3s ease-out; |
|
|
} |
|
|
.pause-filled::after { |
|
|
content: ''; |
|
|
width: 3px; |
|
|
height: 12px; |
|
|
background: currentColor; |
|
|
display: inline-block; |
|
|
margin-right: 2px; |
|
|
} |
|
|
.pause-filled::before { |
|
|
content: ''; |
|
|
width: 3px; |
|
|
height: 12px; |
|
|
background: currentColor; |
|
|
display: inline-block; |
|
|
margin-right: 2px; |
|
|
} |
|
|
</style> |
|
|
|