Files
audiobook/frontend/src/App.tsx
Paul Bailey 4a53c897c5 feat: create professional, polished UI with perfect spacing
- 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
2025-12-09 13:29:48 +00:00

311 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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