|
|
<script> |
|
|
import { |
|
|
Play, |
|
|
Download, |
|
|
Loader2, |
|
|
AlertCircle, |
|
|
ChevronDown, |
|
|
Copy, |
|
|
RefreshCw, |
|
|
Share, |
|
|
MoreHorizontal, |
|
|
Settings, |
|
|
Sliders, |
|
|
Pause, |
|
|
SkipBack, |
|
|
SkipForward, |
|
|
Layout, |
|
|
Code, |
|
|
X, |
|
|
RotateCcw, |
|
|
} 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 = 'Lily'; |
|
|
let selectedModel = 'Chatterbox'; |
|
|
let mode = 'api'; |
|
|
let viewMode = 'ui'; |
|
|
let modelDropdownOpen = false; |
|
|
let isGenerating = false; |
|
|
let codeHistory = []; |
|
|
let setupCode = generateSetupCode(); |
|
|
let importCode = null; |
|
|
let audioUrl = null; |
|
|
let copyNotification = null; |
|
|
let exaggeration = 0.25; |
|
|
let temperature = 0.7; |
|
|
let showSettings = true; |
|
|
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 currentUsername = null; |
|
|
|
|
|
const models = [ |
|
|
{ id: 'chatterbox', name: 'Chatterbox', badge: 'recommended' }, |
|
|
{ id: 'kokoro', name: 'Kokoro', badge: 'coming soon', disabled: true }, |
|
|
]; |
|
|
|
|
|
const voices = [ |
|
|
{ |
|
|
id: 'lily', |
|
|
name: 'Lily', |
|
|
description: 'Warm, 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: 'andrew', |
|
|
name: 'Andrew', |
|
|
description: 'Older British man who speaks clearly and kindly', |
|
|
sample: '/voices/andrew.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/andrew.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', |
|
|
}, |
|
|
{ |
|
|
id: 'pirate', |
|
|
name: 'Pirate', |
|
|
description: 'Young pirate 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', |
|
|
}, |
|
|
]; |
|
|
|
|
|
async function generateSpeech() { |
|
|
if (!text.trim()) return; |
|
|
|
|
|
|
|
|
const response = await fetch('/api/auth/user', { credentials: 'include' }); |
|
|
if (!response.ok) { |
|
|
window.dispatchEvent(new CustomEvent('show-login-prompt')); |
|
|
return; |
|
|
} |
|
|
|
|
|
isGenerating = true; |
|
|
audioUrl = null; |
|
|
currentTime = 0; |
|
|
|
|
|
|
|
|
if (!importCode) { |
|
|
importCode = generateImportCode(); |
|
|
} |
|
|
|
|
|
const ttsCode = generateTTSCode(); |
|
|
|
|
|
isPlaying = false; |
|
|
|
|
|
audioTitle = text.length > 30 ? text.substring(0, 30) + '...' : text; |
|
|
|
|
|
try { |
|
|
const requestBody = { |
|
|
text: text, |
|
|
voice_id: selectedVoice.toLowerCase(), |
|
|
model_id: selectedModel.toLowerCase(), |
|
|
mode: mode, |
|
|
parameters: { |
|
|
exaggeration: exaggeration, |
|
|
temperature: temperature, |
|
|
}, |
|
|
}; |
|
|
|
|
|
const response = await fetch('/api/tts/generate', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
credentials: 'include', // Include session cookie |
|
|
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; |
|
|
|
|
|
|
|
|
addCodeToHistory(ttsCode, { |
|
|
type: 'audio', |
|
|
url: result.audio_url, |
|
|
title: audioTitle, |
|
|
duration: result.duration, |
|
|
}); |
|
|
|
|
|
if (viewMode === 'ui') { |
|
|
|
|
|
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(); |
|
|
} |
|
|
} |
|
|
|
|
|
function handleClickOutside(event) { |
|
|
if (!event.target.closest('.model-dropdown')) { |
|
|
modelDropdownOpen = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function addCodeToHistory(code, result = null) { |
|
|
const entry = { |
|
|
id: Date.now() + Math.random(), |
|
|
code, |
|
|
result, |
|
|
}; |
|
|
codeHistory = [...codeHistory, entry]; |
|
|
saveHistoryToStorage(); |
|
|
return entry; |
|
|
} |
|
|
|
|
|
function saveHistoryToStorage() { |
|
|
if (!currentUsername) return; |
|
|
const storageKey = `hfstudio_history_${currentUsername}`; |
|
|
const historyData = { |
|
|
username: currentUsername, |
|
|
setupCode, |
|
|
importCode, |
|
|
history: codeHistory, |
|
|
}; |
|
|
localStorage.setItem(storageKey, JSON.stringify(historyData)); |
|
|
} |
|
|
|
|
|
function loadHistoryFromStorage() { |
|
|
if (!currentUsername) return; |
|
|
const storageKey = `hfstudio_history_${currentUsername}`; |
|
|
const stored = localStorage.getItem(storageKey); |
|
|
if (stored) { |
|
|
try { |
|
|
const data = JSON.parse(stored); |
|
|
if (data.username === currentUsername) { |
|
|
setupCode = generateSetupCode(); |
|
|
importCode = data.importCode || null; |
|
|
codeHistory = data.history || []; |
|
|
} |
|
|
} catch (e) { |
|
|
console.error('Error loading history:', e); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function resetHistory() { |
|
|
codeHistory = []; |
|
|
setupCode = generateSetupCode(); |
|
|
importCode = null; |
|
|
if (currentUsername) { |
|
|
const storageKey = `hfstudio_history_${currentUsername}`; |
|
|
localStorage.removeItem(storageKey); |
|
|
} |
|
|
} |
|
|
|
|
|
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://localhost:${port}/api/v1")`; |
|
|
} 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 generateTTSCode() { |
|
|
|
|
|
const selectedVoiceData = voices.find((v) => v.name === selectedVoice); |
|
|
const voiceUrl = selectedVoiceData?.preview_url || selectedVoiceData?.sample; |
|
|
|
|
|
if (mode === 'local') { |
|
|
return `text = """${text}""" |
|
|
|
|
|
# audio is in bytes format |
|
|
audio = client.text_to_speech( |
|
|
text, |
|
|
extra_body={ |
|
|
"audio_url": "${voiceUrl}", |
|
|
"exaggeration": ${exaggeration}, |
|
|
"temperature": ${temperature} |
|
|
} |
|
|
)`; |
|
|
} else { |
|
|
return `text = """${text}""" |
|
|
|
|
|
# audio is in bytes format |
|
|
audio = client.text_to_speech( |
|
|
text, |
|
|
extra_body={ |
|
|
"audio_url": "${voiceUrl}", |
|
|
"exaggeration": ${exaggeration}, |
|
|
"temperature": ${temperature} |
|
|
} |
|
|
)`; |
|
|
} |
|
|
} |
|
|
|
|
|
function generateSaveCode() { |
|
|
return `# Save the audio to a file |
|
|
output_filename = "output_speech.wav" |
|
|
|
|
|
with open(output_filename, "wb") as f: |
|
|
f.write(audio_bytes) |
|
|
|
|
|
print(f"✓ Audio saved to {output_filename}") |
|
|
|
|
|
# Optional: Play the audio (requires additional packages) |
|
|
# from playsound import playsound |
|
|
# playsound(output_filename)`; |
|
|
} |
|
|
|
|
|
function copyToClipboard(text, message = 'Copied to clipboard!') { |
|
|
navigator.clipboard.writeText(text).then(() => { |
|
|
copyNotification = message; |
|
|
setTimeout(() => { |
|
|
copyNotification = null; |
|
|
}, 2000); |
|
|
}); |
|
|
} |
|
|
|
|
|
function showError(message, details = '') { |
|
|
errorMessage = message; |
|
|
errorDetails = details; |
|
|
showErrorModal = true; |
|
|
} |
|
|
|
|
|
function closeErrorModal() { |
|
|
showErrorModal = false; |
|
|
errorMessage = ''; |
|
|
errorDetails = ''; |
|
|
} |
|
|
|
|
|
function copyErrorMessage() { |
|
|
const fullError = errorDetails ? `${errorMessage}\n\nDetails:\n${errorDetails}` : errorMessage; |
|
|
copyToClipboard(fullError, 'Error message copied!'); |
|
|
} |
|
|
|
|
|
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 (importCode) { |
|
|
parts.push(`## Imports (Python)\n\n\`\`\`python\n${importCode}\n\`\`\``); |
|
|
} |
|
|
|
|
|
|
|
|
codeHistory.forEach((entry, i) => { |
|
|
parts.push(`## Cell ${i + 1}\n\n\`\`\`python\n${entry.code}\n\`\`\``); |
|
|
}); |
|
|
|
|
|
const markdownContent = parts.join('\n\n'); |
|
|
copyToClipboard(markdownContent, 'All code copied as Markdown!'); |
|
|
} |
|
|
|
|
|
onMount(() => { |
|
|
|
|
|
const checkUsername = () => { |
|
|
|
|
|
}; |
|
|
|
|
|
checkUsername(); |
|
|
|
|
|
|
|
|
window.addEventListener('storage', (e) => { |
|
|
if (e.key === 'hf_access_token') { |
|
|
checkUsername(); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
async function fetchUserInfo(token) { |
|
|
try { |
|
|
const response = await fetch('https://huggingface.co/api/whoami-v2', { |
|
|
headers: { |
|
|
Authorization: `Bearer ${token}`, |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
const userData = await response.json(); |
|
|
currentUsername = |
|
|
userData.name || userData.fullname || userData.login || userData.username || 'User'; |
|
|
loadHistoryFromStorage(); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error fetching user info:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
$: if (mode) { |
|
|
setupCode = generateSetupCode(); |
|
|
|
|
|
if (importCode) { |
|
|
importCode = generateImportCode(); |
|
|
} |
|
|
} |
|
|
|
|
|
function toggleHistoryAudio(entry) { |
|
|
if (!entry.audioElement) { |
|
|
|
|
|
entry.audioElement = new Audio(entry.result.url); |
|
|
entry.audioElement.addEventListener('ended', () => { |
|
|
entry.isPlaying = false; |
|
|
codeHistory = [...codeHistory]; |
|
|
}); |
|
|
} |
|
|
|
|
|
if (entry.isPlaying) { |
|
|
entry.audioElement.pause(); |
|
|
entry.isPlaying = false; |
|
|
} else { |
|
|
|
|
|
codeHistory.forEach((e) => { |
|
|
if (e !== entry && e.isPlaying && e.audioElement) { |
|
|
e.audioElement.pause(); |
|
|
e.isPlaying = false; |
|
|
} |
|
|
}); |
|
|
entry.audioElement.play(); |
|
|
entry.isPlaying = true; |
|
|
} |
|
|
codeHistory = [...codeHistory]; |
|
|
} |
|
|
|
|
|
function downloadHistoryAudio(url, title) { |
|
|
const link = document.createElement('a'); |
|
|
link.href = url; |
|
|
link.download = `${title || 'audio'}.wav`; |
|
|
document.body.appendChild(link); |
|
|
link.click(); |
|
|
document.body.removeChild(link); |
|
|
} |
|
|
|
|
|
function formatDuration(seconds) { |
|
|
if (!seconds) return '0:00'; |
|
|
const mins = Math.floor(seconds / 60); |
|
|
const secs = Math.floor(seconds % 60); |
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`; |
|
|
} |
|
|
</script> |
|
|
|
|
|
<div class="flex flex-col h-full" on:click={handleClickOutside}> |
|
|
<!-- Header --> |
|
|
<header class="border-b border-gray-200 bg-white"> |
|
|
<div class="flex items-center justify-end px-4 py-2"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<!-- View mode toggle --> |
|
|
<div class="flex items-center bg-gray-100 rounded-md p-0.5"> |
|
|
<button |
|
|
class="flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded transition-colors {viewMode === |
|
|
'ui' |
|
|
? 'bg-white shadow-sm' |
|
|
: 'text-gray-600'}" |
|
|
on:click={() => (viewMode = 'ui')} |
|
|
> |
|
|
<Layout size={14} /> |
|
|
UI |
|
|
</button> |
|
|
<button |
|
|
class="flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded transition-colors relative {viewMode === |
|
|
'code' |
|
|
? 'bg-white shadow-sm' |
|
|
: 'text-gray-600'}" |
|
|
on:click={() => (viewMode = 'code')} |
|
|
> |
|
|
<Code size={14} /> |
|
|
Code Recorder |
|
|
{#if codeHistory.length > 0} |
|
|
<span |
|
|
class="ml-1 px-1.5 py-0.5 text-xs bg-gray-500 text-white rounded-full min-w-[18px] h-[18px] flex items-center justify-center" |
|
|
> |
|
|
{codeHistory.length} |
|
|
</span> |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<!-- Main content area --> |
|
|
{#if viewMode === 'ui'} |
|
|
<div class="flex-1 flex"> |
|
|
<!-- Main content area --> |
|
|
<div class="flex-1 flex flex-col p-6"> |
|
|
<!-- Text input area --> |
|
|
<div class="flex-1 pb-24"> |
|
|
<textarea |
|
|
bind:value={text} |
|
|
class="w-full h-full p-6 bg-white resize-none border-0 focus:outline-none text-gray-900 text-base leading-relaxed" |
|
|
placeholder="In a hole in the ground there lived a hobbit." |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<!-- Fixed bottom generate button --> |
|
|
<div class="fixed bottom-0 left-56 right-80 p-4 bg-white border-t border-gray-200"> |
|
|
<div class="flex items-center justify-between mb-3"> |
|
|
<span class="text-sm text-gray-500">{text.length} / 5,000 characters</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={generateSpeech} |
|
|
disabled={isGenerating || !text.trim()} |
|
|
class="w-full px-6 py-3 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-2 shadow-sm" |
|
|
> |
|
|
{#if isGenerating} |
|
|
<Loader2 size={20} class="animate-spin" /> |
|
|
Generating... |
|
|
{:else} |
|
|
<Play size={20} /> |
|
|
Generate speech |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<!-- Generated audio section --> |
|
|
{#if audioUrl} |
|
|
<div class="p-4 border border-gray-200 rounded-lg bg-white"> |
|
|
<!-- Audio title and voice info --> |
|
|
<div class="flex items-center gap-3 mb-4"> |
|
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div> |
|
|
<div class="flex-1"> |
|
|
<h3 class="font-medium text-gray-900 text-sm">{audioTitle}</h3> |
|
|
<p class="text-xs text-gray-500">{selectedVoice} • Created 1 second ago</p> |
|
|
</div> |
|
|
<!-- Mini action buttons --> |
|
|
<div class="flex items-center gap-2"> |
|
|
<button |
|
|
on:click={shareAudio} |
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors" |
|
|
> |
|
|
<Share size={14} class="text-gray-600" /> |
|
|
<span class="text-gray-700">Share</span> |
|
|
</button> |
|
|
<button |
|
|
on:click={downloadAudio} |
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors" |
|
|
> |
|
|
<span class="text-gray-700">Download</span> |
|
|
<Download size={14} class="text-gray-600" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Mini audio controls --> |
|
|
<div class="flex items-center gap-3 mb-4"> |
|
|
<!-- Play/Pause button --> |
|
|
<button |
|
|
on:click={togglePlayPause} |
|
|
class="w-8 h-8 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors" |
|
|
> |
|
|
{#if isPlaying} |
|
|
<div class="pause-filled text-white"></div> |
|
|
{:else} |
|
|
<Play size={14} class="text-white ml-0.5" /> |
|
|
{/if} |
|
|
</button> |
|
|
|
|
|
<!-- Progress bar --> |
|
|
<div class="flex-1 flex items-center gap-2"> |
|
|
<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> |
|
|
|
|
|
<!-- Full audio player controls --> |
|
|
<div class="flex items-center gap-4 mb-4"> |
|
|
<!-- Skip back button --> |
|
|
<button class="p-2 hover:bg-gray-100 rounded-full" title="Skip back"> |
|
|
<SkipBack size={20} class="text-gray-600" /> |
|
|
</button> |
|
|
|
|
|
<!-- Play/Pause button --> |
|
|
<button |
|
|
on:click={togglePlayPause} |
|
|
class="w-12 h-12 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors" |
|
|
> |
|
|
{#if isPlaying} |
|
|
<div class="pause-filled text-white scale-150"></div> |
|
|
{:else} |
|
|
<Play size={20} class="text-white ml-0.5" /> |
|
|
{/if} |
|
|
</button> |
|
|
|
|
|
<!-- Skip forward button --> |
|
|
<button class="p-2 hover:bg-gray-100 rounded-full" title="Skip forward"> |
|
|
<SkipForward size={20} class="text-gray-600" /> |
|
|
</button> |
|
|
|
|
|
<!-- Progress bar --> |
|
|
<div class="flex-1 flex items-center gap-3"> |
|
|
<span class="text-xs text-gray-500 font-mono">{formatTime(currentTime)}</span> |
|
|
<div class="flex-1 h-1 bg-gray-200 rounded-full"> |
|
|
<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> |
|
|
|
|
|
<!-- Action buttons --> |
|
|
<div class="flex items-center gap-2"> |
|
|
<button |
|
|
on:click={shareAudio} |
|
|
class="flex items-center gap-2 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50" |
|
|
> |
|
|
<Share size={14} /> |
|
|
Share |
|
|
</button> |
|
|
<button |
|
|
on:click={downloadAudio} |
|
|
class="p-2 hover:bg-gray-100 rounded-md" |
|
|
title="Download" |
|
|
> |
|
|
<Download size={16} class="text-gray-600" /> |
|
|
</button> |
|
|
<button class="p-2 hover:bg-gray-100 rounded-md" title="More options"> |
|
|
<MoreHorizontal size={16} class="text-gray-600" /> |
|
|
</button> |
|
|
</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> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<!-- Right panel --> |
|
|
<div class="w-80 border-l border-gray-200 bg-white p-3 overflow-y-auto"> |
|
|
<!-- Model selector --> |
|
|
<div class="mb-4 relative model-dropdown"> |
|
|
<h3 class="text-sm font-medium text-gray-900 mb-2">Model</h3> |
|
|
<button |
|
|
on:click={() => (modelDropdownOpen = !modelDropdownOpen)} |
|
|
class="w-full p-2.5 border border-gray-200 rounded-lg bg-white text-xs focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent appearance-none bg-no-repeat bg-right pr-10 shadow-sm text-left flex items-center justify-between" |
|
|
> |
|
|
<span> |
|
|
{#each models as model} |
|
|
{#if model.name === selectedModel} |
|
|
{model.name}{#if model.badge} <span class="text-xs text-gray-500" |
|
|
>({model.badge})</span |
|
|
>{/if} |
|
|
{/if} |
|
|
{/each} |
|
|
</span> |
|
|
<ChevronDown size={14} class="text-gray-500" /> |
|
|
</button> |
|
|
|
|
|
{#if modelDropdownOpen} |
|
|
<div |
|
|
class="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10" |
|
|
> |
|
|
{#each models as model} |
|
|
<button |
|
|
class="w-full px-3 py-1.5 text-left transition-colors text-xs {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-xs text-gray-500" |
|
|
>({model.badge})</span |
|
|
>{/if} |
|
|
</button> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<!-- Pricing info --> |
|
|
<div class="mt-1.5 text-xs text-gray-500"> |
|
|
Estimated $0.025 per 1000 characters • <a |
|
|
href="https://huggingface.co/settings/billing" |
|
|
target="_blank" |
|
|
class="text-amber-600 hover:text-amber-700 underline">Billing ⤴</a |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="mb-4"> |
|
|
<div class="mb-2"> |
|
|
<h3 class="text-sm font-medium text-gray-900">Voice</h3> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-1.5"> |
|
|
{#each voices as voice} |
|
|
<button |
|
|
class="w-full flex items-center justify-between p-1.5 rounded-lg hover:bg-gray-50 transition-colors text-left group border border-transparent |
|
|
{voice.name === selectedVoice ? 'bg-gray-100 border-gray-200' : ''}" |
|
|
on:click={() => (selectedVoice = voice.name)} |
|
|
> |
|
|
<div class="flex items-center gap-2.5 flex-1 min-w-0"> |
|
|
<div |
|
|
class="w-8 h-8 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold flex-shrink-0" |
|
|
> |
|
|
{voice.name[0]} |
|
|
</div> |
|
|
<div class="flex-1 min-w-0"> |
|
|
<div class="text-xs font-medium text-gray-900">{voice.name}</div> |
|
|
<div class="text-xs text-gray-500 leading-relaxed"> |
|
|
{voice.description} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
on:click={(e) => playSampleVoice(voice, e)} |
|
|
class="p-1.5 rounded-full hover:bg-gray-200 transition-colors flex-shrink-0 ml-2 w-7 h-7 flex items-center justify-center" |
|
|
title="Play sample" |
|
|
> |
|
|
{#if playingSampleVoice === voice.name} |
|
|
<Pause size={14} class="text-gray-600" /> |
|
|
{:else} |
|
|
<Play size={14} class="text-gray-600" /> |
|
|
{/if} |
|
|
</button> |
|
|
</button> |
|
|
{/each} |
|
|
|
|
|
<!-- Clone voice option --> |
|
|
<button |
|
|
class="w-full flex items-center justify-between p-1.5 rounded-lg opacity-50 cursor-not-allowed text-left border border-transparent" |
|
|
disabled |
|
|
> |
|
|
<div class="flex items-center gap-2.5 flex-1 min-w-0"> |
|
|
<div |
|
|
class="w-8 h-8 bg-gray-400 rounded-full flex items-center justify-center text-white text-xs font-medium flex-shrink-0" |
|
|
> |
|
|
+ |
|
|
</div> |
|
|
<div class="flex-1 min-w-0"> |
|
|
<div class="text-xs font-medium text-gray-600">Clone your voice</div> |
|
|
<div class="text-xs text-gray-400">(coming soon)</div> |
|
|
</div> |
|
|
</div> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-3 pt-3 border-t border-gray-200"> |
|
|
<!-- Exaggeration control --> |
|
|
<div> |
|
|
<div class="flex justify-between mb-0.5"> |
|
|
<label for="exaggeration-slider" class="text-xs font-medium text-gray-700" |
|
|
>Exaggeration</label |
|
|
> |
|
|
<span class="text-xs text-gray-500">{exaggeration.toFixed(2)}</span> |
|
|
</div> |
|
|
<input |
|
|
id="exaggeration-slider" |
|
|
type="range" |
|
|
bind:value={exaggeration} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf" |
|
|
/> |
|
|
<div class="flex justify-between text-xs text-gray-400 mt-0.5"> |
|
|
<span>None</span> |
|
|
<span>Exaggerated</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Stability control --> |
|
|
<div> |
|
|
<div class="flex justify-between mb-0.5"> |
|
|
<label for="temperature-slider" class="text-xs font-medium text-gray-700" |
|
|
>Stability</label |
|
|
> |
|
|
<span class="text-xs text-gray-500">{temperature.toFixed(2)}</span> |
|
|
</div> |
|
|
<input |
|
|
id="temperature-slider" |
|
|
type="range" |
|
|
bind:value={temperature} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf" |
|
|
/> |
|
|
<div class="flex justify-between text-xs text-gray-400 mt-0.5"> |
|
|
<span>More stable</span> |
|
|
<span>More variable</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- GitHub link at bottom --> |
|
|
<div class="mt-auto pt-3 flex justify-end"> |
|
|
<a |
|
|
href="https://github.com/gradio-app/hfstudio" |
|
|
target="_blank" |
|
|
class="p-2 text-gray-400 hover:text-gray-600 transition-colors" |
|
|
title="View on GitHub" |
|
|
> |
|
|
<svg |
|
|
xmlns="http://www.w3.org/2000/svg" |
|
|
width="20" |
|
|
height="20" |
|
|
viewBox="0 0 24 24" |
|
|
fill="currentColor" |
|
|
> |
|
|
<path |
|
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" |
|
|
/> |
|
|
</svg> |
|
|
</a> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{:else} |
|
|
<!-- Code view --> |
|
|
<div class="flex-1 bg-gray-50 overflow-y-auto"> |
|
|
<div class="max-w-4xl mx-auto p-8"> |
|
|
<!-- Header --> |
|
|
<div class="mb-6"> |
|
|
<div> |
|
|
<h2 class="text-2xl font-semibold text-gray-900">Code Recorder</h2> |
|
|
<p class="text-sm text-gray-600 mt-1"> |
|
|
{#if mode === 'local'} |
|
|
Python code to reproduce your actions using a local HFStudio server |
|
|
{:else} |
|
|
Python code to reproduce your actions via the API |
|
|
{/if} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<!-- Toggle and Copy All button row --> |
|
|
<div class="flex items-center justify-between mt-4"> |
|
|
<!-- API/Local Mode Toggle --> |
|
|
<div class="flex items-center bg-gray-100 rounded-md p-0.5"> |
|
|
<button |
|
|
class="px-3 py-1 text-sm font-medium rounded transition-colors {mode === 'api' |
|
|
? 'bg-white shadow-sm' |
|
|
: 'text-gray-600'}" |
|
|
on:click={() => (mode = 'api')} |
|
|
> |
|
|
API |
|
|
</button> |
|
|
<button |
|
|
class="px-3 py-1 text-sm font-medium rounded transition-colors {mode === 'local' |
|
|
? 'bg-white shadow-sm' |
|
|
: 'text-gray-600'}" |
|
|
on:click={() => (mode = 'local')} |
|
|
> |
|
|
Local |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
{#if codeHistory.length > 0 || setupCode || importCode} |
|
|
<div class="flex items-center gap-2"> |
|
|
<button |
|
|
on:click={resetHistory} |
|
|
class="flex items-center bg-red-50 hover:bg-red-100 rounded-md px-3 py-1.5 transition-colors" |
|
|
title="Clear history" |
|
|
> |
|
|
<RotateCcw size={16} class="text-red-600" /> |
|
|
<span class="ml-2 text-sm font-medium text-red-600">Reset history</span> |
|
|
</button> |
|
|
<button |
|
|
on:click={copyAllCode} |
|
|
class="flex items-center bg-gray-100 hover:bg-gray-200 rounded-md px-3 py-1.5 transition-colors" |
|
|
> |
|
|
<Copy size={16} class="text-gray-600" /> |
|
|
<span class="ml-2 text-sm font-medium text-gray-600">Copy all as Markdown</span> |
|
|
</button> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Code sections --> |
|
|
<div class="space-y-6"> |
|
|
<!-- Setup Section - Always shown --> |
|
|
{#if setupCode} |
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> |
|
|
<div |
|
|
class="flex items-center justify-between px-4 py-2 bg-amber-50 border-b border-amber-200" |
|
|
> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span class="text-sm font-medium text-amber-900">Setup (Run in Terminal)</span> |
|
|
<span class="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded" |
|
|
>Run once</span |
|
|
> |
|
|
</div> |
|
|
<button |
|
|
on:click={() => copyToClipboard(setupCode)} |
|
|
class="p-1.5 hover:bg-amber-100 rounded transition-colors" |
|
|
title="Copy setup code" |
|
|
> |
|
|
<Copy size={14} class="text-amber-600" /> |
|
|
</button> |
|
|
</div> |
|
|
<div class="relative"> |
|
|
{#if setupCode === 'pip install huggingface-hub'} |
|
|
<pre class="p-4 overflow-x-auto bg-gray-50"><code |
|
|
class="language-bash text-sm text-black">{setupCode}</code |
|
|
></pre> |
|
|
{:else} |
|
|
<pre class="p-4 overflow-x-auto bg-gray-50"><code class="language-bash text-sm" |
|
|
>{@html Prism.highlight(setupCode, Prism.languages.bash, 'bash')}</code |
|
|
></pre> |
|
|
{/if} |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<!-- Import Section --> |
|
|
{#if importCode} |
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> |
|
|
<div |
|
|
class="flex items-center justify-between px-4 py-2 bg-blue-50 border-b border-blue-200" |
|
|
> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span class="text-sm font-medium text-blue-900">Imports (Python)</span> |
|
|
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">Run once</span |
|
|
> |
|
|
</div> |
|
|
<button |
|
|
on:click={() => copyToClipboard(importCode)} |
|
|
class="p-1.5 hover:bg-blue-100 rounded transition-colors" |
|
|
title="Copy import code" |
|
|
> |
|
|
<Copy size={14} class="text-blue-600" /> |
|
|
</button> |
|
|
</div> |
|
|
<div class="relative"> |
|
|
<pre class="p-4 overflow-x-auto bg-gray-50"><code class="language-python text-sm" |
|
|
>{@html Prism.highlight(importCode, Prism.languages.python, 'python')}</code |
|
|
></pre> |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<!-- Show "start using UI" message when no import code or history --> |
|
|
{#if !importCode && codeHistory.length === 0} |
|
|
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center"> |
|
|
<p class="text-gray-500">Start using the UI to see generated code here</p> |
|
|
{#if currentUsername} |
|
|
<p class="text-xs text-gray-400 mt-2">Logged in as: {currentUsername}</p> |
|
|
{/if} |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<!-- History entries --> |
|
|
{#each codeHistory as entry, i (entry.id)} |
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm"> |
|
|
<!-- Code cell --> |
|
|
<div class="border-b border-gray-200"> |
|
|
<div |
|
|
class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100" |
|
|
> |
|
|
<span class="text-sm font-medium text-gray-700">Cell {i + 1}</span> |
|
|
<button |
|
|
on:click={() => copyToClipboard(entry.code)} |
|
|
class="p-1.5 hover:bg-gray-200 rounded transition-colors" |
|
|
title="Copy code" |
|
|
> |
|
|
<Copy size={14} class="text-gray-600" /> |
|
|
</button> |
|
|
</div> |
|
|
<div class="relative"> |
|
|
<pre class="p-4 overflow-x-auto bg-gray-50"><code class="language-python text-sm" |
|
|
>{@html Prism.highlight(entry.code, Prism.languages.python, 'python')}</code |
|
|
></pre> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Result (audio player) --> |
|
|
{#if entry.result && entry.result.type === 'audio'} |
|
|
<div class="bg-gradient-to-b from-gray-50 to-white p-4"> |
|
|
<div class="bg-white rounded-lg border border-gray-200 p-4 shadow-sm"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div class="flex items-center gap-3 flex-1"> |
|
|
<button |
|
|
on:click={() => toggleHistoryAudio(entry)} |
|
|
class="w-10 h-10 bg-gradient-to-r from-amber-500 to-orange-500 rounded-full flex items-center justify-center text-white hover:from-amber-600 hover:to-orange-600 transition-colors shadow-md" |
|
|
> |
|
|
{#if entry.isPlaying} |
|
|
<Pause size={18} /> |
|
|
{:else} |
|
|
<Play size={18} class="ml-0.5" /> |
|
|
{/if} |
|
|
</button> |
|
|
<div class="flex-1"> |
|
|
<div class="text-sm font-medium text-gray-900 truncate"> |
|
|
{entry.result.title || 'Generated Audio'} |
|
|
</div> |
|
|
<div class="text-xs text-gray-500"> |
|
|
Duration: {formatDuration(entry.result.duration || 0)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="flex items-center gap-1"> |
|
|
<button |
|
|
on:click={() => |
|
|
downloadHistoryAudio(entry.result.url, entry.result.title)} |
|
|
class="p-2 hover:bg-gray-100 rounded-lg transition-colors" |
|
|
title="Download" |
|
|
> |
|
|
<Download size={16} class="text-gray-600" /> |
|
|
</button> |
|
|
<button |
|
|
class="p-2 hover:bg-gray-100 rounded-lg transition-colors" |
|
|
title="Share" |
|
|
> |
|
|
<Share size={16} class="text-gray-600" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<audio |
|
|
bind:this={entry.audioElement} |
|
|
src={entry.result.url} |
|
|
on:ended={() => (entry.isPlaying = false)} |
|
|
class="hidden" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
{/each} |
|
|
</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} |
|
|
|
|
|
<!-- 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={copyErrorMessage} |
|
|
class="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors" |
|
|
> |
|
|
<Copy size={16} /> |
|
|
Copy Error |
|
|
</button> |
|
|
<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} |
|
|
</div> |
|
|
|
|
|
<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; |
|
|
} |
|
|
|
|
|
@keyframes sweep { |
|
|
0% { |
|
|
left: -100%; |
|
|
} |
|
|
20% { |
|
|
left: -100%; |
|
|
} |
|
|
80% { |
|
|
left: 100%; |
|
|
} |
|
|
100% { |
|
|
left: 100%; |
|
|
} |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
0% { |
|
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); |
|
|
} |
|
|
50% { |
|
|
box-shadow: 0 0 0 6px rgba(251, 191, 36, 0.4); |
|
|
} |
|
|
100% { |
|
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); |
|
|
} |
|
|
} |
|
|
</style> |
|
|
|