{currentReaderPageData?.imageUrl ? ( -
+
+ {imageLoadingStates[currentReaderPage] && ( +
+
+ + + + +
+
+ )} {`Page setImageLoadingStates(prev => ({ ...prev, [currentReaderPage]: true }))} + onLoad={() => setImageLoadingStates(prev => ({ ...prev, [currentReaderPage]: false }))} + onError={() => setImageLoadingStates(prev => ({ ...prev, [currentReaderPage]: false }))} /> + {/* Page navigation overlays */} + {canGoPrev && ( + + )} + {canGoNext && ( + + )} +
+ ) : currentReaderPageData?.pct !== undefined ? ( +
+
+ + + + +
+ {currentReaderPageData.pct}% +
+
+

Generating Page {currentReaderPage}

+

AI is creating your manga page...

) : (
-
📖
-

Page {currentReaderPage} is not ready yet

+ + + +

Page {currentReaderPage} Not Ready

+

This page hasn't been generated yet

)}
{/* Audio Player */} {audioUrl && ( -
-
- -
-
+
+
+
+
+ {isPlaying ? ( + + + + ) : ( + + + + )} +
+
+ +
+
+
+
{dialogues.length > 0 && ( -

🎭 {dialogues.length} dialogue(s) for this page

+
+ + + + {dialogues.length} dialogue{dialogues.length !== 1 ? 's' : ''} +
)}
-
+
{ttsUsage && ( -

- ⚡ Flash v2.5 | {ttsUsage.characterCount?.toLocaleString()}/{ttsUsage.characterLimit?.toLocaleString()} chars -

+ + {ttsUsage.characterCount?.toLocaleString()}/{ttsUsage.characterLimit?.toLocaleString()} chars used + )}
@@ -343,9 +533,91 @@ export default function EpisodeReader() {
)} + {/* Audio Error */} + {audioError && ( +
+ + + + {audioError} + +
+ )} + + {/* Keyboard Help Modal */} + {showKeyboardHelp && ( +
setShowKeyboardHelp(false)} + > +
e.stopPropagation()} + role="dialog" + aria-labelledby="keyboard-help-title" + aria-modal="true" + > +
+

Keyboard Shortcuts

+ +
+
+ {[ + { keys: ['←', '→'], description: 'Navigate between pages' }, + { keys: ['Space'], description: 'Play/Pause audio or generate if not available' }, + { keys: ['Enter'], description: 'Generate audio narration' }, + { keys: ['Esc'], description: 'Exit reader mode' }, + { keys: ['?', 'H'], description: 'Show this help dialog' }, + ].map((shortcut, index) => ( +
+
+ {shortcut.keys.map((key, keyIndex) => ( + + {keyIndex > 0 && or} + + {key} + + + ))} +
+ {shortcut.description} +
+ ))} +
+
+ +
+
+
+ )} + {/* Keyboard Navigation Hint */} -
- Use ← → keys to navigate +
+ + + + Press ? for shortcuts
@@ -356,65 +628,84 @@ export default function EpisodeReader() {
{/* Header */} -
+
- - + + - Back to Create + Back to Create - +
{completedPages > 0 && ( )} -
- {episode?.rendererModel && ( - + {episode?.rendererModel && ( +
+ {episode.rendererModel} - )} -
+
+ )}
- -

- {episode?.title || 'Loading...'} + +

+ {episode?.title || ( + + )}

- + {/* Progress Bar */} -
-
+
+ role="progressbar" + aria-valuenow={progressPercentage} + aria-valuemin={0} + aria-valuemax={100} + aria-label={`Episode generation progress: ${progressPercentage}%`} + > + {progressPercentage > 0 && ( +
+ )} +
- -
- + +
+ {completedPages} of 10 pages completed - + {progressPercentage}% complete
- + {isGenerating && ( -
- +
+ - AI is generating your manga pages... + AI is generating your manga pages...
)}
@@ -442,57 +733,74 @@ export default function EpisodeReader() { {/* Full Page Modal */} {fullPageView && ( -
-
+
setFullPageView(null)} + role="dialog" + aria-modal="true" + aria-labelledby="full-page-title" + > +
e.stopPropagation()}> - -
+ +
-

Page {String(fullPageView.page).padStart(2, '0')}

-
- {fullPageView.page > 1 && ( +

+ Page {String(fullPageView.page).padStart(2, '0')} +

+
+ {fullPageView.page > 1 && sorted.find(p => p.page === fullPageView.page - 1)?.imageUrl && ( )} - {fullPageView.page < 10 && ( + {fullPageView.page < 10 && sorted.find(p => p.page === fullPageView.page + 1)?.imageUrl && ( )}
- -
+ +
{`Page
- + {fullPageView.seed && ( -
- Seed: #{fullPageView.seed} | Version: {fullPageView.version || 1} +
+ Seed: #{fullPageView.seed} | Version: {fullPageView.version || 1}
)}
@@ -502,25 +810,46 @@ export default function EpisodeReader() { {/* Completion Message */} {!isGenerating && completedPages === 10 && ( -
-
+
+
-
- +
+
-

Manga Complete!

-

Your 10-page manga episode has been generated successfully.

-
- - - Edit In Studio + + + + + Edit In Studio - - Create Another + + + + + Create Another
diff --git a/pages/index.tsx b/pages/index.tsx index 9ad1e7e..46c7503 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -4,6 +4,21 @@ import Layout from '../components/Layout'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE || '/api'; +// Character limits +const TITLE_MAX_LENGTH = 100; +const DESCRIPTION_MAX_LENGTH = 500; + +// Validation state type +interface FieldErrors { + title?: string; + description?: string; + genreTags?: string; + tone?: string; + setting?: string; + visualVibe?: string; + castInput?: string; +} + export default function Home() { const r = useRouter(); const [title, setTitle] = useState('Shadow Sketch'); @@ -15,9 +30,12 @@ export default function Home() { const [castInput, setCastInput] = useState('Aoi\nKenji'); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState({}); + const [touched, setTouched] = useState>(new Set()); const [styleRefs, setStyleRefs] = useState([]); const [apiUp, setApiUp] = useState(null); const [planningStatus, setPlanningStatus] = useState(''); + const [currentStep, setCurrentStep] = useState(''); const [heroTitle, setHeroTitle] = useState('Create Your AI Manga'); const [heroSubtitle, setHeroSubtitle] = useState('Transform your ideas into stunning manga pages with AI-powered storytelling and image generation'); const eventSourceRef = useRef(null); @@ -49,6 +67,83 @@ export default function Home() { }); }, []); + // Validation function + const validateField = (name: string, value: string): string | undefined => { + switch (name) { + case 'title': + if (!value.trim()) return 'Title is required'; + if (value.length > TITLE_MAX_LENGTH) return `Title must be ${TITLE_MAX_LENGTH} characters or less`; + return undefined; + case 'description': + if (value.length > DESCRIPTION_MAX_LENGTH) return `Description must be ${DESCRIPTION_MAX_LENGTH} characters or less`; + return undefined; + case 'genreTags': + if (!value.trim()) return 'At least one genre tag is recommended'; + return undefined; + case 'tone': + if (!value.trim()) return 'Tone helps guide the story mood'; + return undefined; + case 'setting': + if (!value.trim()) return 'Setting provides important context'; + return undefined; + case 'visualVibe': + if (!value.trim()) return 'Visual style reference helps generate better art'; + return undefined; + case 'castInput': + const characters = value.split('\n').filter(s => s.trim()); + if (characters.length === 0) return 'At least one character is required'; + if (characters.length > 10) return 'Maximum 10 characters allowed'; + return undefined; + default: + return undefined; + } + }; + + // Validate all fields + const validateForm = (): boolean => { + const errors: FieldErrors = {}; + errors.title = validateField('title', title); + errors.description = validateField('description', description); + errors.genreTags = validateField('genreTags', genreTags); + errors.tone = validateField('tone', tone); + errors.setting = validateField('setting', setting); + errors.visualVibe = validateField('visualVibe', visualVibe); + errors.castInput = validateField('castInput', castInput); + + setFieldErrors(errors); + + // Return true if no errors + return !Object.values(errors).some(error => error !== undefined); + }; + + // Handle field blur + const handleBlur = (fieldName: string) => { + setTouched(prev => new Set(prev).add(fieldName)); + const value = { title, description, genreTags, tone, setting, visualVibe, castInput }[fieldName] as string; + const error = validateField(fieldName, value); + setFieldErrors(prev => ({ ...prev, [fieldName]: error })); + }; + + // Handle field change with validation + const handleFieldChange = (fieldName: string, value: string) => { + // Update the field value + switch (fieldName) { + case 'title': setTitle(value); break; + case 'description': setDescription(value); break; + case 'genreTags': setGenreTags(value); break; + case 'tone': setTone(value); break; + case 'setting': setSetting(value); break; + case 'visualVibe': setVisualVibe(value); break; + case 'castInput': setCastInput(value); break; + } + + // Validate if field has been touched + if (touched.has(fieldName)) { + const error = validateField(fieldName, value); + setFieldErrors(prev => ({ ...prev, [fieldName]: error })); + } + }; + // Cleanup EventSource and timeout on unmount useEffect(() => { return () => { @@ -65,8 +160,19 @@ export default function Home() { async function onSubmit(e: React.FormEvent) { e.preventDefault(); + + // Validate form + if (!validateForm()) { + // Mark all fields as touched to show errors + setTouched(new Set(['title', 'description', 'genreTags', 'tone', 'setting', 'visualVibe', 'castInput'])); + setError('Please fix the validation errors before submitting'); + return; + } + setBusy(true); setError(null); + setCurrentStep('Initializing...'); + try { const cast = castInput .split('\n') @@ -82,6 +188,10 @@ export default function Home() { visual_vibe: visualVibe, cast, }; + + setCurrentStep('Submitting story details...'); + setPlanningStatus('Submitting your story to the AI planner...'); + const planRes = await fetch(`${API_BASE}/planner`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -102,6 +212,8 @@ export default function Home() { planningTimeoutRef.current = null; } + setCurrentStep('Planning story...'); + // Listen for planning status updates const eventSource = new EventSource(`${API_BASE}/episodes/${episodeId}/stream`); eventSourceRef.current = eventSource; @@ -110,9 +222,11 @@ export default function Home() { try { const data = JSON.parse(event.data); if (data.type === 'planning_started' || data.type === 'planning_progress') { - setPlanningStatus(data.message || 'Planning in progress...'); + setCurrentStep('Planning story structure...'); + setPlanningStatus(data.message || 'AI is planning your 10-page story...'); } else if (data.type === 'planning_complete') { - setPlanningStatus(data.message || 'Planning complete!'); + setCurrentStep('Planning complete!'); + setPlanningStatus(data.message || 'Story planning complete!'); if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; @@ -149,6 +263,8 @@ export default function Home() { } catch (err: any) { setError(err.message || String(err)); setBusy(false); + setCurrentStep(''); + setPlanningStatus(''); } } @@ -156,7 +272,8 @@ export default function Home() { try { // upload style refs if provided if (styleRefs.length) { - setPlanningStatus('Uploading style references...'); + setCurrentStep('Uploading style references...'); + setPlanningStatus(`Uploading ${styleRefs.length} style reference image(s)...`); await Promise.all(styleRefs.map(async (file) => { const form = new FormData(); form.append('file', file); @@ -166,14 +283,19 @@ export default function Home() { })); } - setPlanningStatus('Starting page generation...'); + setCurrentStep('Generating pages...'); + setPlanningStatus('Starting AI image generation for 10 pages...'); await fetch(`${API_BASE}/episodes/${episodeId}/generate10`, { method: 'POST' }); + + setCurrentStep('Complete!'); + setPlanningStatus('Redirecting to your manga...'); r.push(`/episodes/${episodeId}`); } catch (err: any) { setError(err.message || String(err)); } finally { setBusy(false); setPlanningStatus(''); + setCurrentStep(''); } } @@ -205,108 +327,269 @@ export default function Home() { {/* Main Form */}
- - {/* Title */} -
-
📖 Story Title
- setTitle(e.target.value)} - className="input-field w-full" - placeholder="Enter your manga title..." - required - /> -
- -