- Add generous padding and spacing throughout (p-10, p-12) - Constrain content width with max-w-4xl and max-w-5xl - Increase font sizes for better hierarchy (text-3xl, text-lg) - Add gradient backgrounds to cards and elements - Implement smooth transitions and hover states - Create professional progress stepper with labels - Add shadow effects with color-matched glows - Increase button padding and font sizes - Add gradient borders on hover - Implement proper visual breathing room - Create polished, premium aesthetic - Add smooth cubic-bezier transitions - Improve form input styling with focus rings - Create professional card hover effects
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { useState } from 'react'
|
||
import './App.css'
|
||
import TextInputSection from './components/TextInputSection'
|
||
import VoiceAssignmentSection from './components/VoiceAssignmentSection'
|
||
import ScriptReviewSection from './components/ScriptReviewSection'
|
||
import AudioPlayerSection from './components/AudioPlayerSection'
|
||
import type { Role, ScriptSegment } from './api/tts'
|
||
import { detectRoles, generateScript, generateAudiobook } from './api/tts'
|
||
|
||
function App() {
|
||
const [text, setText] = useState('')
|
||
const [phase, setPhase] = useState<'input' | 'assignment' | 'review' | 'generated'>('input')
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [roles, setRoles] = useState<Role[]>([])
|
||
const [voiceAssignments, setVoiceAssignments] = useState<Map<string, string>>(new Map())
|
||
const [scriptSegments, setScriptSegments] = useState<ScriptSegment[]>([])
|
||
const [language, setLanguage] = useState('a') // Default: American English
|
||
const [speed, setSpeed] = useState(1.0) // Default: 1.0x
|
||
const [isGeneratingScript, setIsGeneratingScript] = useState(false)
|
||
const [isGenerating, setIsGenerating] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||
|
||
const handleTextChange = (newText: string) => {
|
||
setText(newText)
|
||
}
|
||
|
||
const handleNext = async () => {
|
||
setIsLoading(true)
|
||
setError(null)
|
||
|
||
try {
|
||
// Detect roles in the text
|
||
const response = await detectRoles(text)
|
||
console.log('Detected roles:', response.roles)
|
||
setRoles(response.roles)
|
||
|
||
// Initialize voice assignments map (empty initially)
|
||
setVoiceAssignments(new Map())
|
||
|
||
// Move to assignment phase
|
||
setPhase('assignment')
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to detect roles')
|
||
console.error('Role detection error:', err)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleVoiceChange = (roleId: string, voiceId: string) => {
|
||
setVoiceAssignments(prev => {
|
||
const newMap = new Map(prev)
|
||
newMap.set(roleId, voiceId)
|
||
return newMap
|
||
})
|
||
}
|
||
|
||
const handleLanguageChange = (lang: string) => {
|
||
setLanguage(lang)
|
||
}
|
||
|
||
const handleSpeedChange = (newSpeed: number) => {
|
||
setSpeed(newSpeed)
|
||
}
|
||
|
||
const handleGenerateScript = async () => {
|
||
setIsGeneratingScript(true)
|
||
setError(null)
|
||
|
||
try {
|
||
// Generate script preview with AI segmentation
|
||
const roleIds = roles.map(role => role.id)
|
||
const response = await generateScript({
|
||
text,
|
||
roles: roleIds
|
||
})
|
||
|
||
setScriptSegments(response.segments)
|
||
|
||
// Move to review phase
|
||
setPhase('review')
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to generate script')
|
||
console.error('Script generation error:', err)
|
||
} finally {
|
||
setIsGeneratingScript(false)
|
||
}
|
||
}
|
||
|
||
const handleSkipReview = async () => {
|
||
// Skip review and go directly to generation
|
||
await handleGenerate()
|
||
}
|
||
|
||
const handleSegmentChange = (segmentId: string, newSpeaker: string) => {
|
||
setScriptSegments(prev =>
|
||
prev.map(segment =>
|
||
segment.id === segmentId
|
||
? { ...segment, speaker: newSpeaker }
|
||
: segment
|
||
)
|
||
)
|
||
}
|
||
|
||
const handleGenerate = async () => {
|
||
setIsGenerating(true)
|
||
setError(null)
|
||
|
||
try {
|
||
// Convert voice assignments to the format expected by the API
|
||
const rolesArray = Array.from(voiceAssignments.entries()).map(([id, voice]) => ({
|
||
id,
|
||
voice
|
||
}))
|
||
|
||
// Generate the audiobook
|
||
// If we have script segments (from review phase), use them
|
||
// Otherwise, fall back to text-based generation
|
||
const audioBlob = await generateAudiobook({
|
||
segments: scriptSegments.length > 0 ? scriptSegments : undefined,
|
||
text: scriptSegments.length > 0 ? undefined : text,
|
||
lang: language,
|
||
roles: rolesArray,
|
||
speed
|
||
})
|
||
|
||
// Create a blob URL for the audio
|
||
const url = URL.createObjectURL(audioBlob)
|
||
setAudioUrl(url)
|
||
|
||
// Move to generated phase
|
||
setPhase('generated')
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to generate audiobook')
|
||
console.error('Generation error:', err)
|
||
} finally {
|
||
setIsGenerating(false)
|
||
}
|
||
}
|
||
|
||
// Check if text is long (more than 5000 characters as a threshold)
|
||
const isLongText = text.length > 5000
|
||
|
||
const handleDownload = () => {
|
||
if (!audioUrl) return
|
||
|
||
// Create a temporary anchor element to trigger download
|
||
const link = document.createElement('a')
|
||
link.href = audioUrl
|
||
link.download = 'audiobook.wav'
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
}
|
||
|
||
// Check if all roles have assigned voices
|
||
const allRolesAssigned = roles.length > 0 && roles.every(role => voiceAssignments.has(role.id))
|
||
|
||
return (
|
||
<div className="min-h-screen">
|
||
<div className="container mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||
{/* Header */}
|
||
<div className="text-center mb-16 max-w-3xl mx-auto">
|
||
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-3xl mb-8 shadow-xl shadow-indigo-500/30">
|
||
<span className="text-4xl">🎧</span>
|
||
</div>
|
||
<h1 className="text-5xl sm:text-6xl md:text-7xl font-black text-gray-900 mb-6 tracking-tight">
|
||
Text to Audiobook
|
||
</h1>
|
||
<p className="text-xl text-gray-600 leading-relaxed">
|
||
Transform your stories into immersive audio experiences with AI-powered voices
|
||
</p>
|
||
|
||
{/* Progress Steps */}
|
||
<div className="mt-12 flex justify-center items-center gap-2">
|
||
{['Input', 'Voices', 'Review', 'Done'].map((step, index) => {
|
||
const phases = ['input', 'assignment', 'review', 'generated'];
|
||
const isActive = phase === phases[index];
|
||
const isPast = phases.indexOf(phase) > index;
|
||
|
||
return (
|
||
<div key={step} className="flex items-center">
|
||
<div className="flex flex-col items-center">
|
||
<div className={`flex items-center justify-center w-12 h-12 rounded-full font-bold text-sm transition-all duration-300 ${
|
||
isActive ? 'bg-indigo-600 text-white scale-110 shadow-lg shadow-indigo-500/50' :
|
||
isPast ? 'bg-green-500 text-white' :
|
||
'bg-gray-200 text-gray-500'
|
||
}`}>
|
||
{isPast ? '✓' : index + 1}
|
||
</div>
|
||
<span className={`text-xs font-semibold mt-2 ${isActive ? 'text-indigo-600' : 'text-gray-500'}`}>
|
||
{step}
|
||
</span>
|
||
</div>
|
||
{index < 3 && (
|
||
<div className={`w-16 h-1 mx-3 rounded-full transition-all duration-300 ${isPast || isActive ? 'bg-indigo-600' : 'bg-gray-200'}`}></div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-xl">
|
||
<div className="flex items-start gap-3">
|
||
<span className="text-xl">⚠️</span>
|
||
<div>
|
||
<p className="font-semibold mb-1">Error</p>
|
||
<p className="text-sm">{error}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{phase === 'input' && (
|
||
<TextInputSection
|
||
text={text}
|
||
onChange={handleTextChange}
|
||
onNext={handleNext}
|
||
isLoading={isLoading}
|
||
disabled={false}
|
||
/>
|
||
)}
|
||
|
||
{phase === 'assignment' && (
|
||
<>
|
||
{isLongText && (
|
||
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-xl">
|
||
<div className="flex items-center gap-2">
|
||
<span>⏱️</span>
|
||
<p className="text-sm font-medium">Generation may take several minutes for very long content.</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{console.log('Rendering VoiceAssignmentSection with roles:', roles, 'allRolesAssigned:', allRolesAssigned)}
|
||
<VoiceAssignmentSection
|
||
roles={roles}
|
||
voiceAssignments={voiceAssignments}
|
||
onVoiceChange={handleVoiceChange}
|
||
language={language}
|
||
onLanguageChange={handleLanguageChange}
|
||
speed={speed}
|
||
onSpeedChange={handleSpeedChange}
|
||
onGenerate={handleGenerateScript}
|
||
onSkipReview={handleSkipReview}
|
||
isGenerating={isGeneratingScript}
|
||
allRolesAssigned={allRolesAssigned}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{phase === 'review' && (
|
||
<>
|
||
{isLongText && (
|
||
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-xl">
|
||
<div className="flex items-center gap-2">
|
||
<span>⏱️</span>
|
||
<p className="text-sm font-medium">Generation may take several minutes for very long content.</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<ScriptReviewSection
|
||
segments={scriptSegments}
|
||
roles={roles}
|
||
onSegmentChange={handleSegmentChange}
|
||
onGenerate={handleGenerate}
|
||
onSkipReview={handleSkipReview}
|
||
isGenerating={isGenerating}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{phase === 'generated' && audioUrl && (
|
||
<AudioPlayerSection
|
||
audioUrl={audioUrl}
|
||
onDownload={handleDownload}
|
||
/>
|
||
)}
|
||
|
||
{isGenerating && isLongText && (
|
||
<div className="mt-6 p-6 bg-blue-50 border border-blue-200 rounded-xl text-center">
|
||
<div className="flex flex-col items-center gap-4">
|
||
<div className="animate-spin rounded-full h-10 w-10 border-3 border-indigo-600 border-t-transparent"></div>
|
||
<div>
|
||
<p className="font-semibold text-gray-900 mb-1">Generating Your Audiobook</p>
|
||
<p className="text-sm text-gray-600">This may take several minutes. Please be patient...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isGeneratingScript && (
|
||
<div className="mt-6 p-6 bg-purple-50 border border-purple-200 rounded-xl text-center">
|
||
<div className="flex flex-col items-center gap-4">
|
||
<div className="animate-spin rounded-full h-10 w-10 border-3 border-purple-600 border-t-transparent"></div>
|
||
<div>
|
||
<p className="font-semibold text-gray-900 mb-1">Generating Script Preview</p>
|
||
<p className="text-sm text-gray-600">AI is analyzing your text...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default App
|