hfstudio / frontend /src /routes /+page.svelte
GitHub Action
Sync from GitHub: 63b56d5684377bbd646558687fbe715511ffc27d
a45e6a8
raw
history blame
30.9 kB
<script>
import { Play, Download, Loader2, AlertCircle, ChevronDown, Copy, RefreshCw, Share, MoreHorizontal, Settings, Sliders, Pause, SkipBack, SkipForward, Layout, Code } from 'lucide-svelte';
// Simple Python syntax highlighting
function highlightPython(code) {
return code
// Keywords
.replace(/\b(import|from|def|class|if|else|elif|try|except|finally|with|as|return|for|while|in|is|not|and|or|None|True|False|print|open)\b/g, '<span class="text-blue-600 font-medium">$1</span>')
// Strings
.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"[^"]*"|'[^']*')/g, '<span class="text-green-600">$1</span>')
// Comments
.replace(/(#[^\n]*)/g, '<span class="text-gray-500 italic">$1</span>')
// Numbers
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-purple-600">$1</span>')
// Functions
.replace(/(\w+)(\()/g, '<span class="text-amber-600">$1</span>$2');
}
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 = 'Novia';
let selectedModel = 'Chatterbox';
let mode = 'api'; // 'api' or 'local'
let viewMode = 'ui'; // 'ui' or 'code'
let modelDropdownOpen = false;
let isGenerating = false;
let codeCells = []; // Store code cells for the Code view
let audioUrl = null;
let copyNotification = null; // For copy notifications
let codeButtonFlash = false; // For animating the code button
let speed = 1.0;
let stability = 0.5;
let similarity = 0.75;
let styleExaggeration = 0;
let showSettings = true;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let audioTitle = '';
let audioElement = null;
const models = [
{ id: 'chatterbox', name: 'Chatterbox', badge: 'recommended' },
{ id: 'kokoro', name: 'Kokoro', badge: 'faster but lower quality' },
];
const voices = [
{ id: 'novia', name: 'Novia', description: 'Warm, conversational voice' },
{ id: 'sarah', name: 'Sarah', description: 'Clear, professional tone' },
{ id: 'alex', name: 'Alex', description: 'Friendly, approachable voice' },
{ id: 'emma', name: 'Emma', description: 'Calm, soothing delivery' },
];
async function generateSpeech() {
if (!text.trim()) return;
isGenerating = true;
audioUrl = null;
currentTime = 0;
// Add code cell for this generation
if (codeCells.length === 0) {
addCodeCell('Setup and Import', generateSetupCode());
}
addCodeCell('Generate Speech', generateTTSCode());
// Flash the code button to draw attention
if (viewMode === 'ui') {
codeButtonFlash = true;
setTimeout(() => {
codeButtonFlash = false;
}, 1000);
}
isPlaying = false;
// Create title from first part of text
audioTitle = text.length > 30 ? text.substring(0, 30) + '...' : text;
try {
// Get OAuth token from browser storage or handle authentication
const accessToken = getAccessToken();
console.log('Access token for API call:', accessToken ? 'Found (' + accessToken.substring(0, 10) + '...)' : 'None');
const requestBody = {
text: text,
voice_id: selectedVoice.toLowerCase(),
model_id: selectedModel.toLowerCase(),
mode: mode,
access_token: accessToken,
parameters: {
speed: speed,
stability: stability,
similarity: similarity,
style_exaggeration: styleExaggeration
}
};
console.log('Sending request to /api/tts/generate:', requestBody);
const response = await fetch('/api/tts/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
console.log('API response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('API error response:', errorText);
throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
}
const result = await response.json();
console.log('API response result:', result);
if (result.success && result.audio_url) {
audioUrl = result.audio_url;
// Add code cell for saving the audio
addCodeCell('Save Audio Output', generateSaveCode());
// Flash the code button again for the save code
if (viewMode === 'ui') {
codeButtonFlash = true;
setTimeout(() => {
codeButtonFlash = false;
}, 1000);
}
} else {
// Show error message to user
const errorMessage = result.error || 'Unknown error occurred';
alert(`❌ ${errorMessage}`);
audioUrl = null;
}
} catch (error) {
console.error('Error generating speech:', error);
alert('❌ Network error: Failed to connect to the server');
audioUrl = null;
} finally {
isGenerating = false;
}
}
function getAccessToken() {
console.log('Getting access token...');
// For HuggingFace Spaces, check if we can access the global hf object
if (typeof window !== 'undefined' && window.gradio && window.gradio.auth_token) {
console.log('Found Gradio auth token:', window.gradio.auth_token.substring(0, 10) + '...');
return window.gradio.auth_token;
}
// Check for HF OAuth token in meta tags (common in HF Spaces)
const metaToken = document.querySelector('meta[name="hf-oauth-token"]');
if (metaToken) {
const token = metaToken.getAttribute('content');
if (token) {
console.log('Found token in meta tag:', token.substring(0, 10) + '...');
return token;
}
}
// Try multiple possible token storage locations
const possibleKeys = [
'hf_access_token',
'hf_token',
'huggingface_token',
'oauth_token',
'access_token'
];
for (const key of possibleKeys) {
const token = localStorage.getItem(key);
if (token) {
console.log(`Found token in localStorage['${key}']:`, token.substring(0, 10) + '...');
return token;
}
}
// Also check sessionStorage
for (const key of possibleKeys) {
const token = sessionStorage.getItem(key);
if (token) {
console.log(`Found token in sessionStorage['${key}']:`, token.substring(0, 10) + '...');
return token;
}
}
// Check if there's a token in cookies (for Spaces)
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name && (name.includes('token') || name.includes('hf') || name.includes('oauth'))) {
console.log(`Found potential token in cookie '${name}':`, value ? value.substring(0, 10) + '...' : 'empty');
return decodeURIComponent(value);
}
}
// Try to get it from fetch headers if available in the environment
try {
const authHeader = document.querySelector('script[data-hf-token]');
if (authHeader) {
const token = authHeader.getAttribute('data-hf-token');
if (token) {
console.log('Found token in script data attribute:', token.substring(0, 10) + '...');
return token;
}
}
} catch (e) {
// Ignore errors
}
console.log('No OAuth token found in any storage location');
console.log('Available localStorage keys:', Object.keys(localStorage));
console.log('Available sessionStorage keys:', Object.keys(sessionStorage));
console.log('Available cookies:', document.cookie);
console.log('Available global objects:', {
window: typeof window,
gradio: typeof window?.gradio,
hf: typeof window?.hf,
huggingface: typeof window?.huggingface
});
console.log('URL search params:', new URLSearchParams(window.location.search).toString());
console.log('All meta tags:', Array.from(document.getElementsByTagName('meta')).map(m => ({name: m.name, content: m.content?.substring(0, 20) + '...'})));
return null;
}
function togglePlayPause() {
if (audioElement) {
if (isPlaying) {
audioElement.pause();
} else {
audioElement.play();
}
}
}
function handleAudioLoad() {
if (audioElement) {
duration = audioElement.duration;
// Auto-play when audio loads
audioElement.play();
}
}
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() {
// Share functionality would go here
console.log('Share audio');
}
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
generateSpeech();
}
}
function handleClickOutside(event) {
if (!event.target.closest('.model-dropdown')) {
modelDropdownOpen = false;
}
}
function addCodeCell(title, code, output = null) {
const cell = {
id: Date.now() + Math.random(),
title,
code,
output,
timestamp: new Date().toLocaleTimeString()
};
codeCells = [...codeCells, cell];
return cell;
}
function generateSetupCode() {
const token = getHuggingFaceToken();
const tokenDisplay = token ? 'YOUR_HF_TOKEN' : 'YOUR_HF_TOKEN';
return `# Install required packages
# pip install huggingface-hub
from huggingface_hub import InferenceClient
import base64
import io
# Initialize the client
client = InferenceClient(
provider="fal-ai",
api_key="${tokenDisplay}", # Get your token from https://huggingface.co/settings/tokens
)`;
}
function generateTTSCode() {
const truncatedText = text.length > 100 ? text.substring(0, 100) + '...' : text;
return `# Text to convert to speech
text = """${text}"""
# Voice and model settings
model = "ResembleAI/chatterbox"
voice = "${selectedVoice.toLowerCase()}"
speed = ${speed}
stability = ${stability}
similarity = ${similarity}
style_exaggeration = ${styleExaggeration}
# Generate speech
print("Generating speech...")
try:
audio_bytes = client.text_to_speech(
text,
model=model,
# Note: Voice and other parameters may vary by model
)
print(f"✓ Generated {len(audio_bytes)} bytes of audio")
except Exception as e:
print(f"Error: {e}")`;
}
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 copyAllCode() {
const allCode = codeCells.map(cell => `# ${cell.title}\n${cell.code}`).join('\n\n');
copyToClipboard(allCode, 'All code copied!');
}
// Initialize with setup code
$: if (codeCells.length === 0 && viewMode === 'code') {
addCodeCell('Setup and Import', generateSetupCode());
}
// Track when user changes text
let previousText = text;
$: if (text !== previousText && codeCells.length > 0) {
previousText = text;
// Don't add a cell for every keystroke, user will click generate
}
// Clear code cells when switching back to UI
$: if (viewMode === 'ui') {
codeCells = [];
}
</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-between px-4 py-2">
<div class="flex items-center gap-3">
<!-- 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>
</div>
<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 overflow-hidden {viewMode === 'code' ? 'bg-white shadow-sm' : 'text-gray-600'} {codeButtonFlash ? 'code-flash' : ''}"
on:click={() => viewMode = 'code'}
>
<Code size={14} />
Code
{#if codeButtonFlash}
<span class="flash-sweep"></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">
{#if mode === 'local'}
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-2">
<AlertCircle size={18} class="text-blue-600 mt-0.5 flex-shrink-0" />
<div class="text-sm">
<p class="font-medium text-blue-900">To run locally:</p>
<code class="text-xs bg-blue-100 px-1.5 py-0.5 rounded">pip install hfstudio</code>
<span class="text-blue-700"> and run </span>
<code class="text-xs bg-blue-100 px-1.5 py-0.5 rounded">hfstudio</code>
<span class="text-blue-700"> from your terminal</span>
</div>
</div>
{/if}
<!-- 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="Welcome to our text to speech demo. This technology can transform any written content into natural sounding audio."
/>
</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-4 overflow-y-auto">
<!-- Model selector -->
<div class="mb-6 relative model-dropdown">
<h3 class="font-medium text-gray-900 mb-3">Model</h3>
<button
on:click={() => modelDropdownOpen = !modelDropdownOpen}
class="w-full p-3 border border-gray-200 rounded-lg bg-white text-sm 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}&nbsp;<span class="text-xs text-gray-500">({model.badge})</span>{/if}
{/if}
{/each}
</span>
<ChevronDown size={16} 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-2 text-left hover:bg-gray-50 transition-colors text-sm {model.name === selectedModel ? 'bg-gray-100' : ''}"
on:click={() => {
selectedModel = model.name;
modelDropdownOpen = false;
}}
>
{model.name}{#if model.badge}&nbsp;<span class="text-xs text-gray-500">({model.badge})</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<div class="mb-6">
<div class="mb-3">
<h3 class="font-medium text-gray-900">Voice</h3>
</div>
<div class="space-y-1">
{#each voices as voice}
<button
class="w-full flex items-center justify-between p-2 rounded-md hover:bg-gray-50 transition-colors text-left
{voice.name === selectedVoice ? 'bg-gray-100' : ''}"
on:click={() => selectedVoice = voice.name}
>
<div class="flex items-center gap-2">
<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-medium">
{voice.name[0]}
</div>
<span class="text-sm font-medium">{voice.name}</span>
</div>
<div class="text-xs text-gray-500">
{voice.description}
</div>
</button>
{/each}
<!-- Clone voice option -->
<button
class="w-full flex items-center justify-between p-2 rounded-md opacity-50 cursor-not-allowed text-left"
disabled
>
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-gray-400 rounded-full flex items-center justify-center text-white text-xs font-medium">
+
</div>
<span class="text-sm font-medium text-gray-600">Clone your voice</span>
</div>
<div class="text-xs text-gray-400">
(coming soon)
</div>
</button>
</div>
</div>
<div class="space-y-4 pt-4 border-t border-gray-200">
<!-- Speed control -->
<div>
<div class="flex justify-between mb-1">
<label class="text-sm font-medium text-gray-700">Speed</label>
<span class="text-sm text-gray-500">{speed.toFixed(1)}x</span>
</div>
<input
type="range"
bind:value={speed}
min="0.5"
max="2"
step="0.1"
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-1">
<span>0.5x</span>
<span>2.0x</span>
</div>
</div>
<!-- Stability control -->
<div>
<div class="flex justify-between mb-1">
<label class="text-sm font-medium text-gray-700">Stability</label>
<span class="text-sm text-gray-500">{(stability * 100).toFixed(0)}%</span>
</div>
<input
type="range"
bind:value={stability}
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>
<!-- Similarity control -->
<div>
<div class="flex justify-between mb-1">
<label class="text-sm font-medium text-gray-700">Similarity</label>
<span class="text-sm text-gray-500">{(similarity * 100).toFixed(0)}%</span>
</div>
<input
type="range"
bind:value={similarity}
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>
<!-- Style control -->
<div>
<div class="flex justify-between mb-1">
<label class="text-sm font-medium text-gray-700">Style</label>
<span class="text-sm text-gray-500">
{styleExaggeration === 0 ? 'None' : 'Exaggerated'}
</span>
</div>
<input
type="range"
bind:value={styleExaggeration}
min="0"
max="1"
step="1"
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf"
/>
</div>
</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 with copy all button -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-semibold text-gray-900">Integration Code</h2>
<p class="text-sm text-gray-600 mt-1">Python code to reproduce your actions via the API</p>
</div>
{#if codeCells.length > 0}
<button
on:click={copyAllCode}
class="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Copy size={16} />
Copy All
</button>
{/if}
</div>
<!-- Code cells -->
{#if codeCells.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>
</div>
{:else}
<div class="space-y-4">
{#each codeCells as cell (cell.id)}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<!-- Cell header -->
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700">{cell.title}</span>
<span class="text-xs text-gray-500">{cell.timestamp}</span>
</div>
<button
on:click={() => copyToClipboard(cell.code)}
class="p-1.5 hover:bg-gray-200 rounded transition-colors"
title="Copy code"
>
<Copy size={14} class="text-gray-600" />
</button>
</div>
<!-- Code content -->
<div class="relative">
<pre class="p-4 overflow-x-auto bg-gray-50"><code class="language-python text-sm">{@html highlightPython(cell.code)}</code></pre>
</div>
<!-- Output (if any) -->
{#if cell.output}
<div class="px-4 py-2 bg-gray-900 text-green-400 font-mono text-xs border-t border-gray-200">
{cell.output}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</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}
</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%;
}
100% {
left: 100%;
}
}
.flash-sweep {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
transparent 0%,
rgba(251, 191, 36, 0.4) 25%,
rgba(249, 115, 22, 0.6) 50%,
rgba(251, 191, 36, 0.4) 75%,
transparent 100%);
animation: sweep 0.8s ease-out;
pointer-events: none;
}
.code-flash {
animation: pulse 0.3s ease-out;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
}
50% {
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.3);
}
100% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
}
}
</style>