story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete
This commit is contained in:
360
frontend/src/components/StoryWriter/Phases/StoryExport.tsx
Normal file
360
frontend/src/components/StoryWriter/Phases/StoryExport.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
|
||||
interface StoryExportProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
}
|
||||
|
||||
const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||||
const [videoProgress, setVideoProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
if (state.storyContent) {
|
||||
navigator.clipboard.writeText(state.storyContent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (state.storyContent) {
|
||||
const blob = new Blob([state.storyContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `story-${Date.now()}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateVideo = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.sceneImages || state.sceneImages.size === 0) {
|
||||
setError('Please generate images for scenes first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.sceneAudio || state.sceneAudio.size === 0) {
|
||||
setError('Please generate audio for scenes first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVideo(true);
|
||||
setError(null);
|
||||
setVideoProgress(0);
|
||||
|
||||
try {
|
||||
// Prepare image and audio URLs in scene order
|
||||
const imageUrls: string[] = [];
|
||||
const audioUrls: string[] = [];
|
||||
const scenes = state.outlineScenes;
|
||||
|
||||
for (const scene of scenes) {
|
||||
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
|
||||
const imageUrl = state.sceneImages?.get(sceneNumber);
|
||||
const audioUrl = state.sceneAudio?.get(sceneNumber);
|
||||
|
||||
if (imageUrl && audioUrl) {
|
||||
imageUrls.push(imageUrl);
|
||||
audioUrls.push(audioUrl);
|
||||
} else {
|
||||
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
|
||||
throw new Error('Number of images and audio files must match number of scenes');
|
||||
}
|
||||
|
||||
// Generate video
|
||||
const response = await storyWriterApi.generateStoryVideo({
|
||||
scenes: scenes,
|
||||
image_urls: imageUrls,
|
||||
audio_urls: audioUrls,
|
||||
story_title: state.storySetting || 'Story',
|
||||
fps: state.videoFps,
|
||||
transition_duration: state.videoTransitionDuration,
|
||||
});
|
||||
|
||||
if (response.success && response.video) {
|
||||
state.setStoryVideo(response.video.video_url);
|
||||
state.setError(null);
|
||||
setVideoProgress(100);
|
||||
} else {
|
||||
throw new Error('Failed to generate video');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate video';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingVideo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadVideo = () => {
|
||||
if (state.storyVideo) {
|
||||
const videoUrl = storyWriterApi.getVideoUrl(state.storyVideo);
|
||||
const a = document.createElement('a');
|
||||
a.href = videoUrl;
|
||||
a.download = `story-video-${Date.now()}.mp4`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Export Story
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
|
||||
Your story is complete! You can copy it to clipboard or download it as a text file.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!state.storyContent ? (
|
||||
<Alert severity="info">
|
||||
No story content available. Please complete the writing phase first.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* Story Summary */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Story Summary
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for summary box
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
|
||||
<strong>Setting:</strong> {state.storySetting || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
|
||||
<strong>Characters:</strong> {state.characters || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
|
||||
<strong>Style:</strong> {state.writingStyle} | <strong>Tone:</strong> {state.storyTone}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#2C2416' }}>
|
||||
<strong>POV:</strong> {state.narrativePOV} | <strong>Audience:</strong> {state.audienceAgeGroup}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Premise */}
|
||||
{state.premise && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Premise
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={state.premise}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Outline */}
|
||||
{state.outline && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Outline
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
value={state.outline}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Story Content */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Complete Story
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={20}
|
||||
value={state.storyContent}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Video Generation */}
|
||||
{state.isOutlineStructured && state.outlineScenes && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Video Generation
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Generate a video from your story scenes with images and audio narration.
|
||||
{(!state.sceneImages || state.sceneImages.size === 0) && ' Generate images first.'}
|
||||
{(!state.sceneAudio || state.sceneAudio.size === 0) && ' Generate audio first.'}
|
||||
</Alert>
|
||||
|
||||
{isGeneratingVideo && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={videoProgress} sx={{ mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }}>
|
||||
Generating video... {videoProgress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state.storyVideo && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<video
|
||||
controls
|
||||
src={storyWriterApi.getVideoUrl(state.storyVideo)}
|
||||
style={{ width: '100%', maxHeight: '500px' }}
|
||||
>
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#5D4037' }}>
|
||||
Generated story video
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<VideoLibraryIcon />}
|
||||
onClick={handleGenerateVideo}
|
||||
disabled={
|
||||
isGeneratingVideo ||
|
||||
!state.outlineScenes ||
|
||||
!state.sceneImages ||
|
||||
state.sceneImages.size === 0 ||
|
||||
!state.sceneAudio ||
|
||||
state.sceneAudio.size === 0
|
||||
}
|
||||
>
|
||||
{isGeneratingVideo ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Video...
|
||||
</>
|
||||
) : (
|
||||
'Generate Video'
|
||||
)}
|
||||
</Button>
|
||||
{state.storyVideo && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownloadVideo}
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Export Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Button variant="outlined" onClick={handleCopyToClipboard}>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleDownload}>
|
||||
Download as Text File
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryExport;
|
||||
970
frontend/src/components/StoryWriter/Phases/StoryOutline.tsx
Normal file
970
frontend/src/components/StoryWriter/Phases/StoryOutline.tsx
Normal file
@@ -0,0 +1,970 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Chip,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
} from '@mui/material';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi, StoryScene } from '../../../services/storyWriterApi';
|
||||
import { aiApiClient } from '../../../api/client';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
// Define cubic bezier easing arrays as const to preserve tuple types
|
||||
const easeInOut = [0.22, 0.61, 0.36, 1] as const;
|
||||
const easeOut = [0.4, 0, 1, 1] as const;
|
||||
|
||||
const leftPageVariants = {
|
||||
enter: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? -20 : 20,
|
||||
x: direction === 0 ? 0 : direction > 0 ? -80 : 80,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: 'center',
|
||||
}),
|
||||
center: {
|
||||
rotateY: 0,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transformOrigin: 'center',
|
||||
transition: { duration: 0.55, ease: easeInOut },
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? 15 : -15,
|
||||
x: direction === 0 ? 0 : direction > 0 ? 60 : -60,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: 'center',
|
||||
transition: { duration: 0.4, ease: easeOut },
|
||||
}),
|
||||
};
|
||||
|
||||
const rightPageVariants = {
|
||||
enter: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? 25 : -25,
|
||||
x: direction === 0 ? 0 : direction > 0 ? 110 : -110,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: direction >= 0 ? 'right center' : 'left center',
|
||||
}),
|
||||
center: {
|
||||
rotateY: 0,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transformOrigin: 'center',
|
||||
transition: { duration: 0.55, ease: easeInOut },
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? -25 : 25,
|
||||
x: direction === 0 ? 0 : direction > 0 ? -90 : 90,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: direction >= 0 ? 'left center' : 'right center',
|
||||
transition: { duration: 0.4, ease: easeOut },
|
||||
}),
|
||||
};
|
||||
|
||||
interface StoryOutlineProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isGeneratingImages, setIsGeneratingImages] = useState(false);
|
||||
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
|
||||
const [pageDirection, setPageDirection] = useState(0);
|
||||
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
|
||||
// Use state from hook instead of local state
|
||||
const sceneImages = state.sceneImages || new Map<number, string>();
|
||||
const sceneAudio = state.sceneAudio || new Map<number, string>();
|
||||
|
||||
const scenes = state.outlineScenes || [];
|
||||
const hasScenes = state.isOutlineStructured && scenes.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasScenes) {
|
||||
setCurrentSceneIndex(0);
|
||||
setPageDirection(0);
|
||||
}
|
||||
}, [hasScenes]);
|
||||
|
||||
const currentScene = hasScenes ? scenes[currentSceneIndex] : null;
|
||||
const canGoPrev = currentSceneIndex > 0;
|
||||
const canGoNext = hasScenes ? currentSceneIndex < scenes.length - 1 : false;
|
||||
|
||||
// Get the current scene's image URL
|
||||
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
|
||||
const currentSceneImageUrl = sceneImages.get(currentSceneNumber);
|
||||
const hasImageLoadError = imageLoadError.has(currentSceneNumber);
|
||||
|
||||
// Fetch image as blob with authentication
|
||||
useEffect(() => {
|
||||
if (!currentSceneImageUrl || hasImageLoadError || imageBlobUrls.has(currentSceneNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
// Use relative URL path directly (aiApiClient will add base URL and auth)
|
||||
const imageUrl = currentSceneImageUrl.startsWith('/')
|
||||
? currentSceneImageUrl
|
||||
: `/${currentSceneImageUrl}`;
|
||||
// Use aiApiClient to get authenticated response with blob
|
||||
const response = await aiApiClient.get(imageUrl, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const blob = response.data;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
setImageBlobUrls((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(currentSceneNumber, blobUrl);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load image:', err);
|
||||
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts or scenes change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Revoke all blob URLs on unmount
|
||||
imageBlobUrls.forEach((blobUrl) => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null;
|
||||
|
||||
// Reset image load error when scene changes
|
||||
useEffect(() => {
|
||||
setImageLoadError((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(currentSceneNumber);
|
||||
return next;
|
||||
});
|
||||
}, [currentSceneNumber]);
|
||||
|
||||
const handlePrevScene = () => {
|
||||
if (canGoPrev) {
|
||||
setPageDirection(-1);
|
||||
setCurrentSceneIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextScene = () => {
|
||||
if (canGoNext) {
|
||||
setPageDirection(1);
|
||||
setCurrentSceneIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateOutline = async () => {
|
||||
if (!state.premise) {
|
||||
setError('Please generate a premise first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generateOutline(state.premise, request);
|
||||
|
||||
if (response.success && response.outline) {
|
||||
// Handle structured outline (scenes) or plain text outline
|
||||
if (response.is_structured && Array.isArray(response.outline)) {
|
||||
// Structured outline with scenes
|
||||
const scenes = response.outline as StoryScene[];
|
||||
state.setOutlineScenes(scenes);
|
||||
state.setIsOutlineStructured(true);
|
||||
// Also store as formatted text for backward compatibility
|
||||
const formattedOutline = scenes.map((scene, idx) =>
|
||||
`Scene ${scene.scene_number || idx + 1}: ${scene.title}\n${scene.description}`
|
||||
).join('\n\n');
|
||||
state.setOutline(formattedOutline);
|
||||
} else {
|
||||
// Plain text outline
|
||||
state.setOutline(typeof response.outline === 'string' ? response.outline : String(response.outline));
|
||||
state.setOutlineScenes(null);
|
||||
state.setIsOutlineStructured(false);
|
||||
}
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(typeof response.outline === 'string' ? response.outline : 'Failed to generate outline');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate outline';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (state.outline || state.outlineScenes) {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingImages(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await storyWriterApi.generateSceneImages({
|
||||
scenes: state.outlineScenes,
|
||||
provider: state.imageProvider || undefined,
|
||||
width: state.imageWidth,
|
||||
height: state.imageHeight,
|
||||
model: state.imageModel || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.images) {
|
||||
// Store image URLs by scene number
|
||||
const imagesMap = new Map<number, string>();
|
||||
response.images.forEach((image) => {
|
||||
if (image.image_url && !image.error) {
|
||||
imagesMap.set(image.scene_number, image.image_url);
|
||||
}
|
||||
});
|
||||
state.setSceneImages(imagesMap);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error('Failed to generate images');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate images';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAudio = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAudio(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await storyWriterApi.generateSceneAudio({
|
||||
scenes: state.outlineScenes,
|
||||
provider: state.audioProvider,
|
||||
lang: state.audioLang,
|
||||
slow: state.audioSlow,
|
||||
rate: state.audioRate,
|
||||
});
|
||||
|
||||
if (response.success && response.audio_files) {
|
||||
// Store audio URLs by scene number
|
||||
const audioMap = new Map<number, string>();
|
||||
response.audio_files.forEach((audio) => {
|
||||
if (audio.audio_url && !audio.error) {
|
||||
audioMap.set(audio.scene_number, audio.audio_url);
|
||||
}
|
||||
});
|
||||
state.setSceneAudio(audioMap);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error('Failed to generate audio');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate audio';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render structured scenes
|
||||
const renderStructuredScenes = () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 2, color: '#1A1611' }}>
|
||||
Story Scenes ({state.outlineScenes.length} scenes)
|
||||
</Typography>
|
||||
{state.outlineScenes.map((scene: StoryScene, index: number) => (
|
||||
<Accordion
|
||||
key={index}
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for accordions
|
||||
'&:before': {
|
||||
display: 'none', // Remove default border
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Scene {scene.scene_number || index + 1}: {scene.title}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Description:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2, color: '#2C2416' }}>
|
||||
{scene.description}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Image Prompt:</strong>
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={scene.image_prompt}
|
||||
disabled
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{sceneImages && sceneImages.has(scene.scene_number || index + 1) && (
|
||||
<Card
|
||||
sx={{
|
||||
mt: 2,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for cards
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={storyWriterApi.getImageUrl(sceneImages.get(scene.scene_number || index + 1) || '')}
|
||||
alt={`Scene ${scene.scene_number || index + 1}: ${scene.title}`}
|
||||
sx={{ objectFit: 'contain' }}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Generated image for Scene {scene.scene_number || index + 1}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Audio Narration:</strong>
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={scene.audio_narration}
|
||||
disabled
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{sceneAudio && sceneAudio.has(scene.scene_number || index + 1) && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<audio
|
||||
controls
|
||||
src={storyWriterApi.getAudioUrl(sceneAudio.get(scene.scene_number || index + 1) || '')}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#5D4037' }}>
|
||||
Generated audio for Scene {scene.scene_number || index + 1}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{scene.character_descriptions && scene.character_descriptions.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Characters:</strong>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{scene.character_descriptions.map((char, idx) => (
|
||||
<Chip key={idx} label={char} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{scene.key_events && scene.key_events.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Key Events:</strong>
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, mb: 0 }}>
|
||||
{scene.key_events.map((event, idx) => (
|
||||
<li key={idx}>
|
||||
<Typography variant="body2" sx={{ color: '#2C2416' }}>{event}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'.tw-shadow-book': {
|
||||
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.35)',
|
||||
},
|
||||
'.tw-rounded-book': {
|
||||
borderRadius: '20px',
|
||||
},
|
||||
'.tw-page-accent': {
|
||||
background: 'linear-gradient(120deg, #f9e6c8, #f2d8b4)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Story Outline
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
|
||||
Generate and review your story outline based on the premise. You can regenerate it or proceed to writing.
|
||||
</Typography>
|
||||
|
||||
{state.isOutlineStructured && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
Structured outline with {state.outlineScenes?.length || 0} scenes generated. Each scene includes image prompts and audio narration.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!state.premise && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
Please generate a premise first in the Setup phase.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(state.outline || state.outlineScenes) ? (
|
||||
<>
|
||||
{hasScenes ? (
|
||||
<>
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<Box
|
||||
className="tw-shadow-book tw-rounded-book"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
minHeight: 520,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
borderRadius: '20px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.35)',
|
||||
background: 'linear-gradient(120deg, #fff9ef 0%, #f5e1c7 45%, #fff9ef 100%)',
|
||||
border: '1px solid rgba(120, 90, 60, 0.28)',
|
||||
transform: 'perspective(2200px) rotateX(2deg)',
|
||||
mx: 'auto',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: '-10px -24px 28px',
|
||||
background:
|
||||
'radial-gradient(circle at 25% 20%, rgba(255,255,255,0.45) 0%, rgba(255,255,255,0) 42%), radial-gradient(circle at 75% 82%, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 46%)',
|
||||
filter: 'blur(20px)',
|
||||
zIndex: -2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Book spine */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
width: '2px',
|
||||
background: 'linear-gradient(180deg, rgba(120, 90, 60, 0.5) 0%, rgba(120, 90, 60, 0.08) 100%)',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnimatePresence initial={false} custom={pageDirection}>
|
||||
{/* Single container wrapping both pages for page turn animation */}
|
||||
<MotionBox
|
||||
key={`pages-${currentSceneIndex}`}
|
||||
custom={pageDirection}
|
||||
variants={{
|
||||
enter: () => ({
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
opacity: 1,
|
||||
},
|
||||
exit: () => ({
|
||||
opacity: 0,
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Left page */}
|
||||
<MotionBox
|
||||
key={`meta-${currentSceneIndex}`}
|
||||
role="button"
|
||||
aria-label="Previous scene"
|
||||
onClick={handlePrevScene}
|
||||
custom={pageDirection}
|
||||
variants={leftPageVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
sx={{
|
||||
flexBasis: { xs: '100%', md: '48%' },
|
||||
maxWidth: { xs: '100%', md: '48%' },
|
||||
padding: { xs: 3, md: 4, lg: 5 },
|
||||
pr: { xs: 3, md: 5, lg: 6 },
|
||||
borderRight: '1px solid rgba(120, 90, 60, 0.18)',
|
||||
cursor: canGoPrev ? 'pointer' : 'default',
|
||||
background:
|
||||
'linear-gradient(100deg, rgba(255,255,255,0.82) 0%, rgba(250,240,225,0.95) 50%, rgba(242,226,204,0.9) 100%)',
|
||||
boxShadow: 'inset -18px 0 30px rgba(160, 120, 90, 0.18)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&:hover': canGoPrev
|
||||
? {
|
||||
transform: 'translateX(-4px) rotate(-0.3deg)',
|
||||
boxShadow: 'inset -24px 0 50px rgba(145, 110, 72, 0.25)',
|
||||
}
|
||||
: undefined,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 18,
|
||||
bottom: 18,
|
||||
right: '-12px',
|
||||
width: 24,
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(220,190,150,0.25) 0%, rgba(200,160,120,0) 50%, rgba(220,190,150,0.25) 100%)',
|
||||
filter: 'blur(5px)',
|
||||
opacity: 0.8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: '0 0 auto' }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ color: '#7a5335', letterSpacing: 4, fontWeight: 600, display: 'block' }}
|
||||
>
|
||||
Scene {currentScene?.scene_number || currentSceneIndex + 1} of {scenes.length}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
mt: 1,
|
||||
color: '#2C2416',
|
||||
fontFamily: `'Playfair Display', serif`,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
pr: 2,
|
||||
}}
|
||||
>
|
||||
{currentScene?.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
overflowY: 'auto',
|
||||
mt: 3,
|
||||
display: 'grid',
|
||||
gridTemplateRows: currentSceneImageFullUrl ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
|
||||
alignContent: 'start',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{currentSceneImageFullUrl ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1.5 }}
|
||||
>
|
||||
Scene Illustration
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.18), 0 4px 8px rgba(0, 0, 0, 0.12)',
|
||||
border: '3px solid rgba(120, 90, 60, 0.25)',
|
||||
backgroundColor: '#fff',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px) scale(1.01)',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25), 0 6px 12px rgba(0, 0, 0, 0.18)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={currentSceneImageFullUrl}
|
||||
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
minHeight: '300px',
|
||||
maxHeight: '500px',
|
||||
}}
|
||||
onError={() => {
|
||||
// Mark this scene's image as failed to load
|
||||
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Image Prompt
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#3f3224', lineHeight: 1.7 }}>
|
||||
{currentScene?.image_prompt}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Audio Narration
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#3f3224', lineHeight: 1.7 }}>
|
||||
{currentScene?.audio_narration}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{currentScene?.character_descriptions && currentScene?.character_descriptions.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Characters
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{currentScene.character_descriptions.map((char: string, idx: number) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={char}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg, #f9e6c8, #f2d8b4)',
|
||||
color: '#5a3922',
|
||||
fontWeight: 500,
|
||||
border: '1px solid rgba(120, 90, 60, 0.35)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentScene?.key_events && currentScene?.key_events.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Key Events
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2.5, color: '#3f3224', mb: 0, lineHeight: 1.7 }}>
|
||||
{currentScene.key_events.map((event: string, idx: number) => (
|
||||
<li key={idx}>
|
||||
<Typography variant="body2">{event}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Click to turn back
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#a37b55' }}>
|
||||
{canGoPrev ? '← Previous scene' : 'Start of outline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
|
||||
{/* Right page */}
|
||||
<MotionBox
|
||||
key={`story-${currentSceneIndex}`}
|
||||
role="button"
|
||||
aria-label="Next scene"
|
||||
onClick={handleNextScene}
|
||||
custom={pageDirection}
|
||||
variants={rightPageVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
sx={{
|
||||
flexBasis: { xs: '100%', md: '52%' },
|
||||
maxWidth: { xs: '100%', md: '52%' },
|
||||
padding: { xs: 3, md: 4, lg: 5 },
|
||||
pl: { xs: 3, md: 5, lg: 6 },
|
||||
cursor: canGoNext ? 'pointer' : 'default',
|
||||
background:
|
||||
'linear-gradient(260deg, rgba(255,255,255,0.88) 0%, rgba(249,236,215,0.96) 45%, rgba(243,226,206,0.92) 100%)',
|
||||
boxShadow: 'inset 18px 0 30px rgba(160, 120, 90, 0.18)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&:hover': canGoNext
|
||||
? {
|
||||
transform: 'translateX(4px) rotate(0.3deg)',
|
||||
boxShadow: 'inset 24px 0 50px rgba(145, 110, 72, 0.25)',
|
||||
}
|
||||
: undefined,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 18,
|
||||
bottom: 18,
|
||||
left: '-12px',
|
||||
width: 24,
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(220,190,150,0.25) 0%, rgba(200,160,120,0) 50%, rgba(220,190,150,0.25) 100%)',
|
||||
filter: 'blur(5px)',
|
||||
opacity: 0.8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#2C2416',
|
||||
fontSize: '1.08rem',
|
||||
lineHeight: 1.9,
|
||||
fontFamily: `'Merriweather', serif`,
|
||||
whiteSpace: 'pre-line',
|
||||
textAlign: 'justify',
|
||||
textJustify: 'inter-word',
|
||||
textIndent: '2em',
|
||||
hyphens: 'auto',
|
||||
pr: { xs: 0, md: 1.5 },
|
||||
}}
|
||||
>
|
||||
{currentScene?.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Click to turn page
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#a37b55' }}>
|
||||
{canGoNext ? 'Next scene →' : 'End of outline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Page {currentSceneIndex + 1} of {scenes.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={state.outline || ''}
|
||||
onChange={(e) => state.setOutline(e.target.value)}
|
||||
label="Story Outline"
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGenerateOutline}
|
||||
disabled={isGenerating || !state.premise}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
'Regenerate Outline'
|
||||
)}
|
||||
</Button>
|
||||
{state.isOutlineStructured && state.outlineScenes && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ImageIcon />}
|
||||
onClick={handleGenerateImages}
|
||||
disabled={isGeneratingImages || !state.outlineScenes || state.outlineScenes.length === 0}
|
||||
>
|
||||
{isGeneratingImages ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Images...
|
||||
</>
|
||||
) : (
|
||||
'Generate Images'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<VolumeUpIcon />}
|
||||
onClick={handleGenerateAudio}
|
||||
disabled={isGeneratingAudio || !state.outlineScenes || state.outlineScenes.length === 0}
|
||||
>
|
||||
{isGeneratingAudio ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Audio...
|
||||
</>
|
||||
) : (
|
||||
'Generate Audio'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinue}
|
||||
disabled={(!state.outline && !state.outlineScenes) || isGenerating || isGeneratingImages || isGeneratingAudio}
|
||||
>
|
||||
Continue to Writing
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{state.premise
|
||||
? 'Generating outline... If this message persists, please return to Setup and try again.'
|
||||
: 'Please generate a premise first.'}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryOutline;
|
||||
111
frontend/src/components/StoryWriter/Phases/StoryPremise.tsx
Normal file
111
frontend/src/components/StoryWriter/Phases/StoryPremise.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
|
||||
interface StoryPremiseProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const StoryPremise: React.FC<StoryPremiseProps> = ({ state, onNext }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generatePremise(request);
|
||||
|
||||
if (response.success && response.premise) {
|
||||
state.setPremise(response.premise);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.premise || 'Failed to generate premise');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate premise';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (state.premise) {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 4, mt: 2 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Story Premise
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Review and refine your story premise. You can regenerate it or proceed to create the outline.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state.premise ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={8}
|
||||
value={state.premise}
|
||||
onChange={(e) => state.setPremise(e.target.value)}
|
||||
label="Story Premise"
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleRegenerate}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
'Regenerate Premise'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinue}
|
||||
disabled={!state.premise || isGenerating}
|
||||
>
|
||||
Continue to Outline
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
No premise generated yet. Please go back to Setup and generate a premise first.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryPremise;
|
||||
@@ -0,0 +1,499 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Card,
|
||||
CardContent,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { InfoOutlined } from '@mui/icons-material';
|
||||
import { storyWriterApi, StorySetupOption } from '../../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../../api/client';
|
||||
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
|
||||
import { STORY_IDEA_PLACEHOLDERS } from './constants';
|
||||
import { textFieldStyles, cardStyles } from './styles';
|
||||
import {
|
||||
WRITING_STYLES,
|
||||
STORY_TONES,
|
||||
NARRATIVE_POVS,
|
||||
AUDIENCE_AGE_GROUPS,
|
||||
CONTENT_RATINGS,
|
||||
ENDING_PREFERENCES,
|
||||
} from './constants';
|
||||
import { CustomValuesSetters } from './types';
|
||||
|
||||
interface AIStorySetupModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
customValuesSetters: CustomValuesSetters;
|
||||
}
|
||||
|
||||
export const AIStorySetupModal: React.FC<AIStorySetupModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
state,
|
||||
customValuesSetters,
|
||||
}) => {
|
||||
const [storyIdea, setStoryIdea] = useState('');
|
||||
const [isGeneratingSetup, setIsGeneratingSetup] = useState(false);
|
||||
const [setupOptions, setSetupOptions] = useState<StorySetupOption[]>([]);
|
||||
const [selectedOption, setSelectedOption] = useState<number | null>(null);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
const [currentPlaceholder, setCurrentPlaceholder] = useState('');
|
||||
const typingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const charIndexRef = useRef(0);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Rotating placeholder effect for story idea textarea
|
||||
useEffect(() => {
|
||||
// Cleanup function
|
||||
const cleanup = () => {
|
||||
if (typingIntervalRef.current) {
|
||||
clearInterval(typingIntervalRef.current);
|
||||
typingIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Stop all effects if modal is closed or user has entered text
|
||||
if (!open || storyIdea.trim() !== '') {
|
||||
cleanup();
|
||||
setCurrentPlaceholder('');
|
||||
charIndexRef.current = 0;
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
// Start typing animation for current placeholder
|
||||
const placeholder = STORY_IDEA_PLACEHOLDERS[placeholderIndex];
|
||||
charIndexRef.current = 0;
|
||||
setCurrentPlaceholder('');
|
||||
|
||||
// Type out characters one by one
|
||||
typingIntervalRef.current = setInterval(() => {
|
||||
// Check if we should stop
|
||||
if (storyIdea.trim() !== '' || !open) {
|
||||
cleanup();
|
||||
setCurrentPlaceholder('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue typing
|
||||
if (charIndexRef.current < placeholder.length) {
|
||||
setCurrentPlaceholder(placeholder.substring(0, charIndexRef.current + 1));
|
||||
charIndexRef.current += 1;
|
||||
} else {
|
||||
// Finished typing current placeholder
|
||||
cleanup();
|
||||
|
||||
// Wait 4 seconds then move to next placeholder
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (storyIdea.trim() === '' && open) {
|
||||
setPlaceholderIndex((prev) => (prev + 1) % STORY_IDEA_PLACEHOLDERS.length);
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
}, 30);
|
||||
|
||||
return cleanup;
|
||||
}, [open, placeholderIndex, storyIdea]);
|
||||
|
||||
const handleGenerateSetup = async () => {
|
||||
if (!storyIdea.trim()) {
|
||||
setSetupError('Please enter a story idea');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingSetup(true);
|
||||
setSetupError(null);
|
||||
|
||||
try {
|
||||
const response = await storyWriterApi.generateStorySetup({
|
||||
story_idea: storyIdea,
|
||||
});
|
||||
|
||||
if (response.success && response.options && response.options.length === 3) {
|
||||
setSetupOptions(response.options);
|
||||
|
||||
// Extract custom values from all options and add them to custom values lists
|
||||
const newCustomWritingStyles = new Set<string>();
|
||||
const newCustomStoryTones = new Set<string>();
|
||||
const newCustomNarrativePOVs = new Set<string>();
|
||||
const newCustomAudienceAgeGroups = new Set<string>();
|
||||
const newCustomContentRatings = new Set<string>();
|
||||
const newCustomEndingPreferences = new Set<string>();
|
||||
|
||||
response.options.forEach((option) => {
|
||||
// Check if values are custom (not in predefined lists)
|
||||
if (!WRITING_STYLES.includes(option.writing_style)) {
|
||||
newCustomWritingStyles.add(option.writing_style);
|
||||
}
|
||||
if (!STORY_TONES.includes(option.story_tone)) {
|
||||
newCustomStoryTones.add(option.story_tone);
|
||||
}
|
||||
if (!NARRATIVE_POVS.includes(option.narrative_pov)) {
|
||||
newCustomNarrativePOVs.add(option.narrative_pov);
|
||||
}
|
||||
if (!AUDIENCE_AGE_GROUPS.includes(option.audience_age_group)) {
|
||||
newCustomAudienceAgeGroups.add(option.audience_age_group);
|
||||
}
|
||||
if (!CONTENT_RATINGS.includes(option.content_rating)) {
|
||||
newCustomContentRatings.add(option.content_rating);
|
||||
}
|
||||
if (!ENDING_PREFERENCES.includes(option.ending_preference)) {
|
||||
newCustomEndingPreferences.add(option.ending_preference);
|
||||
}
|
||||
});
|
||||
|
||||
// Update custom values state (merge with existing)
|
||||
customValuesSetters.setCustomWritingStyles((prev) =>
|
||||
[...prev, ...Array.from(newCustomWritingStyles)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomStoryTones((prev) =>
|
||||
[...prev, ...Array.from(newCustomStoryTones)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomNarrativePOVs((prev) =>
|
||||
[...prev, ...Array.from(newCustomNarrativePOVs)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomAudienceAgeGroups((prev) =>
|
||||
[...prev, ...Array.from(newCustomAudienceAgeGroups)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomContentRatings((prev) =>
|
||||
[...prev, ...Array.from(newCustomContentRatings)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomEndingPreferences((prev) =>
|
||||
[...prev, ...Array.from(newCustomEndingPreferences)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
} else {
|
||||
throw new Error('Failed to generate story setup options');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Story setup generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StorySetup: Detected subscription error, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data,
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StorySetup: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsGeneratingSetup(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StorySetup: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate story setup options';
|
||||
setSetupError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingSetup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOption = (index: number) => {
|
||||
setSelectedOption(index);
|
||||
};
|
||||
|
||||
const handleApplyOption = () => {
|
||||
if (selectedOption === null || !setupOptions[selectedOption]) {
|
||||
setSetupError('Please select an option');
|
||||
return;
|
||||
}
|
||||
|
||||
const option = setupOptions[selectedOption];
|
||||
|
||||
// Extract and add custom values to dropdowns if they don't exist
|
||||
if (!WRITING_STYLES.includes(option.writing_style)) {
|
||||
customValuesSetters.setCustomWritingStyles((prev) =>
|
||||
prev.includes(option.writing_style) ? prev : [...prev, option.writing_style]
|
||||
);
|
||||
}
|
||||
if (!STORY_TONES.includes(option.story_tone)) {
|
||||
customValuesSetters.setCustomStoryTones((prev) =>
|
||||
prev.includes(option.story_tone) ? prev : [...prev, option.story_tone]
|
||||
);
|
||||
}
|
||||
if (!NARRATIVE_POVS.includes(option.narrative_pov)) {
|
||||
customValuesSetters.setCustomNarrativePOVs((prev) =>
|
||||
prev.includes(option.narrative_pov) ? prev : [...prev, option.narrative_pov]
|
||||
);
|
||||
}
|
||||
if (!AUDIENCE_AGE_GROUPS.includes(option.audience_age_group)) {
|
||||
customValuesSetters.setCustomAudienceAgeGroups((prev) =>
|
||||
prev.includes(option.audience_age_group) ? prev : [...prev, option.audience_age_group]
|
||||
);
|
||||
}
|
||||
if (!CONTENT_RATINGS.includes(option.content_rating)) {
|
||||
customValuesSetters.setCustomContentRatings((prev) =>
|
||||
prev.includes(option.content_rating) ? prev : [...prev, option.content_rating]
|
||||
);
|
||||
}
|
||||
if (!ENDING_PREFERENCES.includes(option.ending_preference)) {
|
||||
customValuesSetters.setCustomEndingPreferences((prev) =>
|
||||
prev.includes(option.ending_preference) ? prev : [...prev, option.ending_preference]
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the selected option to the form
|
||||
state.setPersona(option.persona);
|
||||
state.setStorySetting(option.story_setting);
|
||||
state.setCharacters(option.character_input);
|
||||
state.setPlotElements(option.plot_elements);
|
||||
state.setWritingStyle(option.writing_style);
|
||||
state.setStoryTone(option.story_tone);
|
||||
state.setNarrativePOV(option.narrative_pov);
|
||||
// Normalize audience_age_group value (migrate old format if needed, but preserve custom values)
|
||||
const normalizedAgeGroup =
|
||||
option.audience_age_group === 'Adults'
|
||||
? 'Adults (18+)'
|
||||
: option.audience_age_group === 'Children'
|
||||
? 'Children (5-12)'
|
||||
: option.audience_age_group === 'Young Adults'
|
||||
? 'Young Adults (13-17)'
|
||||
: option.audience_age_group;
|
||||
state.setAudienceAgeGroup(normalizedAgeGroup);
|
||||
state.setContentRating(option.content_rating);
|
||||
state.setEndingPreference(option.ending_preference);
|
||||
|
||||
// Apply story length if provided
|
||||
if (option.story_length) {
|
||||
state.setStoryLength(option.story_length);
|
||||
}
|
||||
|
||||
// Apply premise if provided
|
||||
if (option.premise) {
|
||||
state.setPremise(option.premise);
|
||||
}
|
||||
|
||||
// Apply image/video/audio settings if provided
|
||||
if (option.image_provider !== undefined) {
|
||||
state.setImageProvider(option.image_provider || null);
|
||||
}
|
||||
if (option.image_width !== undefined) {
|
||||
state.setImageWidth(option.image_width);
|
||||
}
|
||||
if (option.image_height !== undefined) {
|
||||
state.setImageHeight(option.image_height);
|
||||
}
|
||||
if (option.image_model !== undefined) {
|
||||
state.setImageModel(option.image_model || null);
|
||||
}
|
||||
if (option.video_fps !== undefined) {
|
||||
state.setVideoFps(option.video_fps);
|
||||
}
|
||||
if (option.video_transition_duration !== undefined) {
|
||||
state.setVideoTransitionDuration(option.video_transition_duration);
|
||||
}
|
||||
if (option.audio_provider !== undefined) {
|
||||
state.setAudioProvider(option.audio_provider);
|
||||
}
|
||||
if (option.audio_lang !== undefined) {
|
||||
state.setAudioLang(option.audio_lang);
|
||||
}
|
||||
if (option.audio_slow !== undefined) {
|
||||
state.setAudioSlow(option.audio_slow);
|
||||
}
|
||||
if (option.audio_rate !== undefined) {
|
||||
state.setAudioRate(option.audio_rate);
|
||||
}
|
||||
|
||||
// Close modal
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setStoryIdea('');
|
||||
setSetupOptions([]);
|
||||
setSelectedOption(null);
|
||||
setSetupError(null);
|
||||
setPlaceholderIndex(0);
|
||||
setCurrentPlaceholder('');
|
||||
charIndexRef.current = 0;
|
||||
// Cleanup intervals
|
||||
if (typingIntervalRef.current) {
|
||||
clearInterval(typingIntervalRef.current);
|
||||
typingIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Generate Story Setup With Alwrity AI</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: '#5D4037' }}>
|
||||
Enter your story idea or basic information. The more details you provide, the better story setups will be generated.
|
||||
</Typography>
|
||||
|
||||
{setupError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setSetupError(null)}>
|
||||
{setupError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
label="Story Idea"
|
||||
placeholder={currentPlaceholder || "Enter your story idea, characters, setting, plot elements, or any other relevant information..."}
|
||||
value={storyIdea}
|
||||
onChange={(e) => setStoryIdea(e.target.value)}
|
||||
sx={{ ...textFieldStyles, mb: 3 }}
|
||||
helperText="Provide as much detail as possible. Include characters, setting, plot, themes, or any story elements you want to explore."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Story Idea Input
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Enter your story idea or concept. The more details you provide, the better the AI can generate tailored story setup options. Include:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
• Main characters and their roles
|
||||
<br />
|
||||
• Setting and time period
|
||||
<br />
|
||||
• Key plot points or conflicts
|
||||
<br />
|
||||
• Themes or messages
|
||||
<br />
|
||||
• Genre or style preferences
|
||||
<br />
|
||||
• Any specific story elements you want
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: '#5D4037' }}>
|
||||
Watch the placeholder examples cycle through for inspiration!
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" edge="end">
|
||||
<InfoOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{isGeneratingSetup && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 3 }}>
|
||||
<CircularProgress size={24} sx={{ mr: 2 }} />
|
||||
<Typography sx={{ color: '#2C2416' }}>Generating story setup options...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{setupOptions.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600, color: '#1A1611' }}>
|
||||
Select one of the following options:
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={selectedOption !== null ? selectedOption.toString() : ''}
|
||||
onChange={(e) => handleSelectOption(Number(e.target.value))}
|
||||
>
|
||||
{setupOptions.map((option, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
sx={{
|
||||
mb: 2,
|
||||
...cardStyles,
|
||||
border: selectedOption === index ? 2 : 1,
|
||||
borderColor: selectedOption === index ? 'primary.main' : 'divider',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleSelectOption(index)}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Radio value={index} checked={selectedOption === index} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: '#1A1611' }}>
|
||||
Option {index + 1}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Persona:</strong> {option.persona}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Setting:</strong> {option.story_setting}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Characters:</strong> {option.character_input}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Plot Elements:</strong> {option.plot_elements}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Style:</strong> {option.writing_style} | <strong>Tone:</strong> {option.story_tone} | <strong>POV:</strong> {option.narrative_pov}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Audience:</strong> {option.audience_age_group} | <strong>Rating:</strong> {option.content_rating} | <strong>Ending:</strong> {option.ending_preference}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: '#5D4037' }}>
|
||||
<strong>Reasoning:</strong> {option.reasoning}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
{setupOptions.length === 0 ? (
|
||||
<Button
|
||||
onClick={handleGenerateSetup}
|
||||
disabled={!storyIdea.trim() || isGeneratingSetup}
|
||||
variant="contained"
|
||||
>
|
||||
{isGeneratingSetup ? 'Generating...' : 'Generate Options'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleApplyOption} disabled={selectedOption === null} variant="contained">
|
||||
Apply Selected Option
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Grid, Typography, Box, FormControlLabel, Checkbox } from '@mui/material';
|
||||
import { SectionProps } from './types';
|
||||
|
||||
export const FeatureCheckboxesSection: React.FC<SectionProps> = ({ state }) => {
|
||||
return (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Story Features
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.enableExplainer}
|
||||
onChange={(e) => state.setEnableExplainer(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Explainer"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.enableIllustration}
|
||||
onChange={(e) => state.setEnableIllustration(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Illustration"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.enableVideoNarration}
|
||||
onChange={(e) => state.setEnableVideoNarration(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Story Video & Narration"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { TextField, Tooltip, IconButton, InputAdornment, Box, Typography } from '@mui/material';
|
||||
import { InfoOutlined } from '@mui/icons-material';
|
||||
|
||||
interface TooltipContent {
|
||||
title: string;
|
||||
description: string;
|
||||
examples?: string[];
|
||||
}
|
||||
|
||||
interface FormFieldWithTooltipProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
type?: string;
|
||||
tooltip: TooltipContent;
|
||||
sx?: any;
|
||||
inputProps?: any;
|
||||
}
|
||||
|
||||
export const FormFieldWithTooltip: React.FC<FormFieldWithTooltipProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
helperText,
|
||||
required = false,
|
||||
multiline = false,
|
||||
rows,
|
||||
type,
|
||||
tooltip,
|
||||
sx,
|
||||
inputProps,
|
||||
}) => {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
helperText={helperText}
|
||||
required={required}
|
||||
multiline={multiline}
|
||||
rows={rows}
|
||||
type={type}
|
||||
sx={sx}
|
||||
InputProps={{
|
||||
...inputProps,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
{tooltip.examples && tooltip.examples.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Examples:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
{tooltip.examples.map((example, index) => (
|
||||
<React.Fragment key={index}>
|
||||
• {example}
|
||||
{index < tooltip.examples!.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" edge="end">
|
||||
<InfoOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Grid,
|
||||
TextField,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Slider,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { SectionProps } from './types';
|
||||
import { textFieldStyles, accordionStyles } from './styles';
|
||||
import { IMAGE_PROVIDERS, AUDIO_PROVIDERS, COMMON_IMAGE_SIZES } from './constants';
|
||||
|
||||
export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) => {
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Generation Settings
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3, color: '#5D4037' }}>
|
||||
Configure image, video, and audio generation options for your story.
|
||||
</Typography>
|
||||
|
||||
{/* Image Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Image Generation Settings
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Image Provider"
|
||||
value={state.imageProvider || ''}
|
||||
onChange={(e) => state.setImageProvider(e.target.value || null)}
|
||||
helperText="Select the image generation provider. Leave as 'Auto' to use the default."
|
||||
sx={textFieldStyles}
|
||||
>
|
||||
{IMAGE_PROVIDERS.map((provider) => (
|
||||
<MenuItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Image Size"
|
||||
value={`${state.imageWidth}x${state.imageHeight}`}
|
||||
onChange={(e) => {
|
||||
const [width, height] = e.target.value.split('x').map(Number);
|
||||
state.setImageWidth(width);
|
||||
state.setImageHeight(height);
|
||||
}}
|
||||
helperText="Select a common image size or set custom dimensions below."
|
||||
sx={textFieldStyles}
|
||||
>
|
||||
{COMMON_IMAGE_SIZES.map((size) => (
|
||||
<MenuItem key={`${size.width}x${size.height}`} value={`${size.width}x${size.height}`}>
|
||||
{size.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Image Width"
|
||||
value={state.imageWidth}
|
||||
onChange={(e) => state.setImageWidth(Number(e.target.value))}
|
||||
inputProps={{ min: 256, max: 2048, step: 64 }}
|
||||
helperText="Image width in pixels (256-2048)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Image Height"
|
||||
value={state.imageHeight}
|
||||
onChange={(e) => state.setImageHeight(Number(e.target.value))}
|
||||
inputProps={{ min: 256, max: 2048, step: 64 }}
|
||||
helperText="Image height in pixels (256-2048)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Image Model (Optional)"
|
||||
value={state.imageModel || ''}
|
||||
onChange={(e) => state.setImageModel(e.target.value || null)}
|
||||
placeholder="Leave empty to use default model"
|
||||
helperText="Specific model to use for image generation (optional)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Video Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Video Generation Settings
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Frames Per Second (FPS)"
|
||||
value={state.videoFps}
|
||||
onChange={(e) => state.setVideoFps(Number(e.target.value))}
|
||||
inputProps={{ min: 15, max: 60, step: 1 }}
|
||||
helperText="Video frame rate (15-60 fps). Higher values create smoother video but larger files."
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Transition Duration: {state.videoTransitionDuration.toFixed(1)}s
|
||||
</Typography>
|
||||
<Slider
|
||||
value={state.videoTransitionDuration}
|
||||
onChange={(_, value) => state.setVideoTransitionDuration(value as number)}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
marks={[
|
||||
{ value: 0, label: '0s' },
|
||||
{ value: 1, label: '1s' },
|
||||
{ value: 2, label: '2s' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Duration of transitions between scenes in seconds
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Audio Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Audio Generation Settings
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Audio Provider"
|
||||
value={state.audioProvider}
|
||||
onChange={(e) => state.setAudioProvider(e.target.value)}
|
||||
helperText="Text-to-speech provider for narration"
|
||||
sx={textFieldStyles}
|
||||
>
|
||||
{AUDIO_PROVIDERS.map((provider) => (
|
||||
<MenuItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Language Code"
|
||||
value={state.audioLang}
|
||||
onChange={(e) => state.setAudioLang(e.target.value)}
|
||||
placeholder="en"
|
||||
helperText="Language code for text-to-speech (e.g., 'en' for English, 'es' for Spanish)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
{state.audioProvider === 'gtts' && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.audioSlow}
|
||||
onChange={(e) => state.setAudioSlow(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Slow Speech (gTTS only)"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{state.audioProvider === 'pyttsx3' && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Speech Rate: {state.audioRate} words/min
|
||||
</Typography>
|
||||
<Slider
|
||||
value={state.audioRate}
|
||||
onChange={(_, value) => state.setAudioRate(value as number)}
|
||||
min={50}
|
||||
max={300}
|
||||
step={10}
|
||||
marks={[
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 150, label: '150' },
|
||||
{ value: 300, label: '300' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Speech rate in words per minute (pyttsx3 only)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { TextField, MenuItem, Tooltip, IconButton, InputAdornment, Box, Typography } from '@mui/material';
|
||||
import { InfoOutlined } from '@mui/icons-material';
|
||||
|
||||
interface TooltipContent {
|
||||
title: string;
|
||||
description: string;
|
||||
examples?: Array<{ label: string; description: string }>;
|
||||
}
|
||||
|
||||
interface SelectFieldWithTooltipProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
helperText?: string;
|
||||
options: string[];
|
||||
customValues?: string[];
|
||||
tooltip: TooltipContent;
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
export const SelectFieldWithTooltip: React.FC<SelectFieldWithTooltipProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
options,
|
||||
customValues = [],
|
||||
tooltip,
|
||||
sx,
|
||||
}) => {
|
||||
const allOptions = [...options, ...customValues];
|
||||
const isCustom = (option: string) => customValues.includes(option);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
helperText={helperText}
|
||||
sx={sx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
{tooltip.examples && tooltip.examples.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" component="div">
|
||||
{tooltip.examples.map((example, index) => (
|
||||
<React.Fragment key={index}>
|
||||
• <strong>{example.label}</strong>: {example.description}
|
||||
{index < tooltip.examples!.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" edge="end">
|
||||
<InfoOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{allOptions.map((option) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
{isCustom(option) && (
|
||||
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main', fontStyle: 'italic' }}>
|
||||
(AI Generated)
|
||||
</Typography>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { Grid } from '@mui/material';
|
||||
import { SelectFieldWithTooltip } from './SelectFieldWithTooltip';
|
||||
import { SectionProps } from './types';
|
||||
import {
|
||||
WRITING_STYLES,
|
||||
STORY_TONES,
|
||||
NARRATIVE_POVS,
|
||||
AUDIENCE_AGE_GROUPS,
|
||||
CONTENT_RATINGS,
|
||||
ENDING_PREFERENCES,
|
||||
STORY_LENGTHS,
|
||||
} from './constants';
|
||||
|
||||
interface StoryConfigurationSectionProps extends SectionProps {
|
||||
normalizedAudienceAgeGroup: string;
|
||||
}
|
||||
|
||||
export const StoryConfigurationSection: React.FC<StoryConfigurationSectionProps> = ({
|
||||
state,
|
||||
customValues,
|
||||
textFieldStyles,
|
||||
normalizedAudienceAgeGroup,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Writing Style */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Writing Style"
|
||||
value={state.writingStyle}
|
||||
onChange={(e) => state.setWritingStyle(e.target.value)}
|
||||
helperText="Choose the narrative style and prose approach"
|
||||
options={WRITING_STYLES}
|
||||
customValues={customValues.customWritingStyles}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Writing Style',
|
||||
description: 'Select the narrative style that best fits your story. This affects sentence structure, vocabulary, and overall prose approach.',
|
||||
examples: [
|
||||
{ label: 'Formal', description: 'Structured, academic, precise language' },
|
||||
{ label: 'Casual', description: 'Conversational, relaxed, everyday language' },
|
||||
{ label: 'Poetic', description: 'Lyrical, metaphorical, rich imagery' },
|
||||
{ label: 'Humorous', description: 'Witty, playful, comedic tone' },
|
||||
{ label: 'Narrative', description: 'Traditional storytelling style' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Story Tone */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Story Tone"
|
||||
value={state.storyTone}
|
||||
onChange={(e) => state.setStoryTone(e.target.value)}
|
||||
helperText="Set the emotional atmosphere and mood of your story"
|
||||
options={STORY_TONES}
|
||||
customValues={customValues.customStoryTones}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Tone',
|
||||
description: 'The tone determines the emotional atmosphere and overall mood of your story. It affects how readers feel while reading.',
|
||||
examples: [
|
||||
{ label: 'Dark', description: 'Serious, grim, somber atmosphere' },
|
||||
{ label: 'Uplifting', description: 'Positive, hopeful, inspiring' },
|
||||
{ label: 'Suspenseful', description: 'Tense, thrilling, edge-of-seat' },
|
||||
{ label: 'Whimsical', description: 'Playful, fanciful, lighthearted' },
|
||||
{ label: 'Mysterious', description: 'Enigmatic, puzzling, intriguing' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Narrative POV */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Narrative Point of View"
|
||||
value={state.narrativePOV}
|
||||
onChange={(e) => state.setNarrativePOV(e.target.value)}
|
||||
helperText="Choose the perspective from which the story is told"
|
||||
options={NARRATIVE_POVS}
|
||||
customValues={customValues.customNarrativePOVs}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Narrative Point of View',
|
||||
description: "Select the perspective from which your story is narrated. This determines how much readers know about characters and events.",
|
||||
examples: [
|
||||
{ label: 'First Person', description: '"I" perspective, limited to one character\'s thoughts' },
|
||||
{ label: 'Third Person Limited', description: '"He/She" perspective, follows one character closely' },
|
||||
{ label: 'Third Person Omniscient', description: '"He/She" perspective, knows all characters\' thoughts' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Audience Age Group */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Audience Age Group"
|
||||
value={normalizedAudienceAgeGroup}
|
||||
onChange={(e) => state.setAudienceAgeGroup(e.target.value)}
|
||||
helperText="Target age group for your story"
|
||||
options={AUDIENCE_AGE_GROUPS}
|
||||
customValues={customValues.customAudienceAgeGroups}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Audience Age Group',
|
||||
description: 'Select the primary target age group. This affects language complexity, themes, and content appropriateness.',
|
||||
examples: [
|
||||
{ label: 'Children (5-12)', description: 'Simple language, clear themes, age-appropriate content' },
|
||||
{ label: 'Young Adults (13-17)', description: 'Moderate complexity, coming-of-age themes' },
|
||||
{ label: 'Adults (18+)', description: 'Complex themes, mature content allowed' },
|
||||
{ label: 'All Ages', description: 'Universal appeal, family-friendly' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Content Rating */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Content Rating"
|
||||
value={state.contentRating}
|
||||
onChange={(e) => state.setContentRating(e.target.value)}
|
||||
helperText="Set the content rating based on themes and material"
|
||||
options={CONTENT_RATINGS}
|
||||
customValues={customValues.customContentRatings}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Content Rating',
|
||||
description: 'Select the appropriate content rating based on themes, language, violence, and mature content in your story.',
|
||||
examples: [
|
||||
{ label: 'G', description: 'General audience, all ages appropriate' },
|
||||
{ label: 'PG', description: 'Parental guidance suggested, mild themes' },
|
||||
{ label: 'PG-13', description: 'Parents strongly cautioned, some mature content' },
|
||||
{ label: 'R', description: 'Restricted, mature themes and content' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Ending Preference */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Ending Preference"
|
||||
value={state.endingPreference}
|
||||
onChange={(e) => state.setEndingPreference(e.target.value)}
|
||||
helperText="Choose how you want your story to conclude"
|
||||
options={ENDING_PREFERENCES}
|
||||
customValues={customValues.customEndingPreferences}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Ending Preference',
|
||||
description: 'Select the type of ending you want for your story. This guides the resolution and final emotional impact.',
|
||||
examples: [
|
||||
{ label: 'Happy', description: 'Positive resolution, characters succeed' },
|
||||
{ label: 'Tragic', description: 'Sad or bittersweet conclusion' },
|
||||
{ label: 'Cliffhanger', description: 'Open ending, sequel potential' },
|
||||
{ label: 'Twist', description: 'Unexpected revelation or turn' },
|
||||
{ label: 'Open-ended', description: 'Ambiguous, reader interpretation' },
|
||||
{ label: 'Bittersweet', description: 'Mixed emotions, realistic outcome' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Story Length */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Story Length"
|
||||
value={state.storyLength}
|
||||
onChange={(e) => state.setStoryLength(e.target.value)}
|
||||
helperText="Choose the target length for your story"
|
||||
options={STORY_LENGTHS}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Length',
|
||||
description: 'Select the target length for your story. This controls how detailed and extensive the generated story will be.',
|
||||
examples: [
|
||||
{ label: 'Short (>1000 words)', description: 'Brief, concise story' },
|
||||
{ label: 'Medium (>5000 words)', description: 'Standard length story with good detail' },
|
||||
{ label: 'Long (>10000 words)', description: 'Extended, detailed story with rich development' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import { Grid, TextField, Button, Box, CircularProgress } from '@mui/material';
|
||||
import { FormFieldWithTooltip } from './FormFieldWithTooltip';
|
||||
import { SectionProps } from './types';
|
||||
|
||||
interface StoryParametersSectionProps extends SectionProps {
|
||||
isRegeneratingPremise: boolean;
|
||||
onRegeneratePremise: () => void;
|
||||
}
|
||||
|
||||
export const StoryParametersSection: React.FC<StoryParametersSectionProps> = ({
|
||||
state,
|
||||
textFieldStyles,
|
||||
isRegeneratingPremise,
|
||||
onRegeneratePremise,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Persona */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Persona"
|
||||
value={state.persona}
|
||||
onChange={(e) => state.setPersona(e.target.value)}
|
||||
placeholder="Describe the author persona (e.g., 'A fantasy writer who loves intricate world-building')"
|
||||
helperText="Define the author's voice, style, and perspective that will guide the story's narrative"
|
||||
required
|
||||
multiline
|
||||
rows={2}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Persona',
|
||||
description: "The persona defines the author's voice and writing style. This shapes how the story is told, the language used, and the overall narrative approach.",
|
||||
examples: [
|
||||
"A fantasy writer who loves intricate world-building and epic quests",
|
||||
"A mystery novelist who specializes in psychological thrillers",
|
||||
"A science fiction author who explores existential themes",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Story Setting */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Story Setting"
|
||||
value={state.storySetting}
|
||||
onChange={(e) => state.setStorySetting(e.target.value)}
|
||||
placeholder="Describe the setting (e.g., 'A medieval kingdom with magic')"
|
||||
helperText="Define the time, place, and environment where your story takes place"
|
||||
required
|
||||
multiline
|
||||
rows={2}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Setting',
|
||||
description: 'The setting establishes the world, time period, and physical environment of your story. Include details about geography, culture, technology, and any unique elements.',
|
||||
examples: [
|
||||
"A medieval kingdom with magic and dragons",
|
||||
"A cyberpunk city in 2087 where corporations rule",
|
||||
"A small coastal town in the 1950s with a dark secret",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Characters */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Characters"
|
||||
value={state.characters}
|
||||
onChange={(e) => state.setCharacters(e.target.value)}
|
||||
placeholder="Describe the main characters (e.g., 'A young wizard apprentice and her mentor')"
|
||||
helperText="Describe the main characters, their roles, relationships, and key traits"
|
||||
required
|
||||
multiline
|
||||
rows={2}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Characters',
|
||||
description: "Define your main characters, their roles in the story, relationships with each other, and key personality traits or backgrounds that drive the narrative.",
|
||||
examples: [
|
||||
"A young wizard apprentice and her wise mentor",
|
||||
"A detective with amnesia and a mysterious informant",
|
||||
"A retired space explorer and their estranged daughter",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Plot Elements */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Plot Elements"
|
||||
value={state.plotElements}
|
||||
onChange={(e) => state.setPlotElements(e.target.value)}
|
||||
placeholder="Describe key plot elements (e.g., 'A quest to find a lost artifact, betrayal, redemption')"
|
||||
helperText="Outline the main events, conflicts, themes, and story arcs that drive the narrative"
|
||||
required
|
||||
multiline
|
||||
rows={3}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Plot Elements',
|
||||
description: 'Describe the key events, conflicts, themes, and story arcs. Include main challenges, obstacles, and the central conflict that drives your story forward.',
|
||||
examples: [
|
||||
"A quest to find a lost artifact, betrayal, redemption",
|
||||
"A murder mystery, conspiracy, memory loss",
|
||||
"Return to a changed world, uncovering hidden truths, rebellion",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Premise */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Story Premise"
|
||||
value={state.premise || ''}
|
||||
onChange={(e) => state.setPremise(e.target.value)}
|
||||
placeholder="Enter or generate a brief premise for your story (1-2 sentences)"
|
||||
helperText="A brief summary of your story concept (1-2 sentences). This will be used to generate the story outline."
|
||||
multiline
|
||||
rows={3}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Premise',
|
||||
description: 'The premise is a brief summary (1-2 sentences) that captures the core concept of your story. It should describe who, where, and what the main challenge or adventure is. This will be used to generate the detailed story outline.',
|
||||
examples: [
|
||||
"A young wizard must find a lost artifact to save her kingdom from darkness.",
|
||||
"A detective with amnesia must solve a murder mystery to uncover their own past.",
|
||||
"A retired space explorer returns to Earth to discover it has changed beyond recognition.",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onRegeneratePremise}
|
||||
disabled={isRegeneratingPremise || !state.persona || !state.storySetting || !state.characters || !state.plotElements}
|
||||
startIcon={isRegeneratingPremise ? <CircularProgress size={16} /> : null}
|
||||
>
|
||||
{isRegeneratingPremise ? 'Regenerating...' : 'Regenerate Premise'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Story setup constants
|
||||
|
||||
export const WRITING_STYLES = [
|
||||
'Formal',
|
||||
'Casual',
|
||||
'Poetic',
|
||||
'Humorous',
|
||||
'Academic',
|
||||
'Journalistic',
|
||||
'Narrative',
|
||||
];
|
||||
|
||||
export const STORY_TONES = [
|
||||
'Dark',
|
||||
'Uplifting',
|
||||
'Suspenseful',
|
||||
'Whimsical',
|
||||
'Melancholic',
|
||||
'Mysterious',
|
||||
'Romantic',
|
||||
'Adventurous',
|
||||
];
|
||||
|
||||
export const NARRATIVE_POVS = [
|
||||
'First Person',
|
||||
'Third Person Limited',
|
||||
'Third Person Omniscient',
|
||||
];
|
||||
|
||||
export const AUDIENCE_AGE_GROUPS = [
|
||||
'Children (5-12)',
|
||||
'Young Adults (13-17)',
|
||||
'Adults (18+)',
|
||||
'All Ages',
|
||||
];
|
||||
|
||||
export const CONTENT_RATINGS = ['G', 'PG', 'PG-13', 'R'];
|
||||
|
||||
export const ENDING_PREFERENCES = [
|
||||
'Happy',
|
||||
'Tragic',
|
||||
'Cliffhanger',
|
||||
'Twist',
|
||||
'Open-ended',
|
||||
'Bittersweet',
|
||||
];
|
||||
|
||||
export const STORY_LENGTHS = [
|
||||
'Short (>1000 words)',
|
||||
'Medium (>5000 words)',
|
||||
'Long (>10000 words)',
|
||||
];
|
||||
|
||||
export const IMAGE_PROVIDERS = [
|
||||
{ value: '', label: 'Auto (Default)' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'huggingface', label: 'HuggingFace' },
|
||||
{ value: 'stability', label: 'Stability AI' },
|
||||
];
|
||||
|
||||
export const AUDIO_PROVIDERS = [
|
||||
{ value: 'gtts', label: 'Google TTS (gTTS)' },
|
||||
{ value: 'pyttsx3', label: 'pyttsx3' },
|
||||
];
|
||||
|
||||
export const COMMON_IMAGE_SIZES = [
|
||||
{ width: 512, height: 512, label: '512x512 (Square)' },
|
||||
{ width: 768, height: 768, label: '768x768 (Square)' },
|
||||
{ width: 1024, height: 1024, label: '1024x1024 (Square)' },
|
||||
{ width: 1024, height: 768, label: '1024x768 (Landscape)' },
|
||||
{ width: 768, height: 1024, label: '768x1024 (Portrait)' },
|
||||
];
|
||||
|
||||
export const STORY_IDEA_PLACEHOLDERS = [
|
||||
"A young wizard discovers a magical artifact in an ancient forest. The artifact holds the power to restore balance to a dying realm, but it comes with a terrible cost. The wizard must choose between saving the world and losing everything they hold dear.",
|
||||
"In a cyberpunk future where memories can be bought and sold, a detective with no past must solve a murder that threatens to expose a conspiracy spanning decades. The deeper they dig, the more they realize their own memories might have been stolen.",
|
||||
"A retired space explorer returns to their home planet after 50 years, only to find it has been transformed into a utopian society that erases all traces of the past. They must uncover the truth about what happened while avoiding the watchful eyes of the perfect world they helped create.",
|
||||
];
|
||||
|
||||
257
frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx
Normal file
257
frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Typography, Box, Button, Alert, Grid, CircularProgress } from '@mui/material';
|
||||
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../../api/client';
|
||||
import { StoryParametersSection } from './StoryParametersSection';
|
||||
import { StoryConfigurationSection } from './StoryConfigurationSection';
|
||||
import { FeatureCheckboxesSection } from './FeatureCheckboxesSection';
|
||||
import { GenerationSettingsSection } from './GenerationSettingsSection';
|
||||
import { AIStorySetupModal } from './AIStorySetupModal';
|
||||
import { textFieldStyles, paperStyles } from './styles';
|
||||
import { AUDIENCE_AGE_GROUPS } from './constants';
|
||||
import { StorySetupProps, CustomValuesState, CustomValuesSetters } from './types';
|
||||
|
||||
const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
|
||||
const [isRegeneratingPremise, setIsRegeneratingPremise] = useState(false);
|
||||
const [isGeneratingOutline, setIsGeneratingOutline] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Track custom values from AI-generated options
|
||||
const [customWritingStyles, setCustomWritingStyles] = useState<string[]>([]);
|
||||
const [customStoryTones, setCustomStoryTones] = useState<string[]>([]);
|
||||
const [customNarrativePOVs, setCustomNarrativePOVs] = useState<string[]>([]);
|
||||
const [customAudienceAgeGroups, setCustomAudienceAgeGroups] = useState<string[]>([]);
|
||||
const [customContentRatings, setCustomContentRatings] = useState<string[]>([]);
|
||||
const [customEndingPreferences, setCustomEndingPreferences] = useState<string[]>([]);
|
||||
|
||||
const customValues: CustomValuesState = {
|
||||
customWritingStyles,
|
||||
customStoryTones,
|
||||
customNarrativePOVs,
|
||||
customAudienceAgeGroups,
|
||||
customContentRatings,
|
||||
customEndingPreferences,
|
||||
};
|
||||
|
||||
const handleGenerateOutlineAndProceed = async () => {
|
||||
if (!state.premise) {
|
||||
setError('Please generate a premise before generating the outline');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingOutline(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generateOutline(state.premise, request);
|
||||
|
||||
if (response.success && response.outline) {
|
||||
if (response.is_structured && Array.isArray(response.outline)) {
|
||||
const scenes = response.outline as StoryScene[];
|
||||
state.setOutlineScenes(scenes);
|
||||
state.setIsOutlineStructured(true);
|
||||
const formattedOutline = scenes
|
||||
.map((scene, idx) => `Scene ${scene.scene_number || idx + 1}: ${scene.title}\n${scene.description}`)
|
||||
.join('\n\n');
|
||||
state.setOutline(formattedOutline);
|
||||
} else {
|
||||
state.setOutline(typeof response.outline === 'string' ? response.outline : String(response.outline));
|
||||
state.setOutlineScenes(null);
|
||||
state.setIsOutlineStructured(false);
|
||||
}
|
||||
state.setError(null);
|
||||
onNext();
|
||||
} else {
|
||||
throw new Error(typeof response.outline === 'string' ? response.outline : 'Failed to generate outline');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
setIsGeneratingOutline(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate outline';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingOutline(false);
|
||||
}
|
||||
};
|
||||
|
||||
const customValuesSetters: CustomValuesSetters = {
|
||||
setCustomWritingStyles,
|
||||
setCustomStoryTones,
|
||||
setCustomNarrativePOVs,
|
||||
setCustomAudienceAgeGroups,
|
||||
setCustomContentRatings,
|
||||
setCustomEndingPreferences,
|
||||
};
|
||||
|
||||
// Get normalized audienceAgeGroup value (fallback to default if invalid, but preserve custom values)
|
||||
const allAudienceAgeGroups = [...AUDIENCE_AGE_GROUPS, ...customAudienceAgeGroups];
|
||||
const normalizedAudienceAgeGroup = allAudienceAgeGroups.includes(state.audienceAgeGroup)
|
||||
? state.audienceAgeGroup
|
||||
: state.audienceAgeGroup === 'Adults'
|
||||
? 'Adults (18+)'
|
||||
: state.audienceAgeGroup === 'Children'
|
||||
? 'Children (5-12)'
|
||||
: state.audienceAgeGroup === 'Young Adults'
|
||||
? 'Young Adults (13-17)'
|
||||
: state.audienceAgeGroup || 'Adults (18+)'; // Preserve custom values instead of defaulting
|
||||
|
||||
// Fix invalid audienceAgeGroup values on mount and when state changes (but preserve custom values)
|
||||
useEffect(() => {
|
||||
// Only normalize if it's an old format value, not a custom value
|
||||
if (
|
||||
state.audienceAgeGroup &&
|
||||
state.audienceAgeGroup !== normalizedAudienceAgeGroup &&
|
||||
!allAudienceAgeGroups.includes(state.audienceAgeGroup) &&
|
||||
(state.audienceAgeGroup === 'Adults' ||
|
||||
state.audienceAgeGroup === 'Children' ||
|
||||
state.audienceAgeGroup === 'Young Adults')
|
||||
) {
|
||||
state.setAudienceAgeGroup(normalizedAudienceAgeGroup);
|
||||
}
|
||||
}, [state.audienceAgeGroup, normalizedAudienceAgeGroup, state.setAudienceAgeGroup, allAudienceAgeGroups]);
|
||||
|
||||
const handleRegeneratePremise = async () => {
|
||||
// Validate required fields
|
||||
if (!state.persona || !state.storySetting || !state.characters || !state.plotElements) {
|
||||
setError('Please fill in all required fields (Persona, Setting, Characters, Plot Elements)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegeneratingPremise(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generatePremise(request);
|
||||
|
||||
if (response.success && response.premise) {
|
||||
state.setPremise(response.premise);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.premise || 'Failed to generate premise');
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StorySetup: Detected subscription error in regenerate premise, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data,
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StorySetup: Global subscription error handler triggered successfully');
|
||||
setIsRegeneratingPremise(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StorySetup: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate premise';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsRegeneratingPremise(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={paperStyles}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Story Setup
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
|
||||
Configure your story parameters and premise. Fill in the required fields and click "Next: Generate Outline" to continue.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* AI Story Setup Button */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Button variant="outlined" color="primary" size="large" onClick={() => setIsModalOpen(true)} sx={{ mb: 2 }}>
|
||||
Generate Story Setup With Alwrity AI
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Story Parameters Section */}
|
||||
<StoryParametersSection
|
||||
state={state}
|
||||
customValues={customValues}
|
||||
textFieldStyles={textFieldStyles}
|
||||
isRegeneratingPremise={isRegeneratingPremise}
|
||||
onRegeneratePremise={handleRegeneratePremise}
|
||||
/>
|
||||
|
||||
{/* Story Configuration Section */}
|
||||
<StoryConfigurationSection
|
||||
state={state}
|
||||
customValues={customValues}
|
||||
textFieldStyles={textFieldStyles}
|
||||
normalizedAudienceAgeGroup={normalizedAudienceAgeGroup}
|
||||
/>
|
||||
|
||||
{/* Feature Checkboxes Section */}
|
||||
<FeatureCheckboxesSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
|
||||
</Grid>
|
||||
|
||||
{/* Generation Settings Section */}
|
||||
<GenerationSettingsSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
|
||||
|
||||
{/* Generate Button */}
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleGenerateOutlineAndProceed}
|
||||
disabled={
|
||||
!state.persona ||
|
||||
!state.storySetting ||
|
||||
!state.characters ||
|
||||
!state.plotElements ||
|
||||
!state.premise ||
|
||||
isGeneratingOutline
|
||||
}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{isGeneratingOutline ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Outline...
|
||||
</>
|
||||
) : (
|
||||
'Generate Outline'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* AI Story Setup Modal */}
|
||||
<AIStorySetupModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
state={state}
|
||||
customValuesSetters={customValuesSetters}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorySetup;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Shared styles for Story Setup components
|
||||
|
||||
export const textFieldStyles = {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#5D4037',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#3E2723',
|
||||
borderWidth: '2px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#3E2723',
|
||||
fontWeight: 500,
|
||||
'&.Mui-focused': {
|
||||
color: '#1A1611',
|
||||
fontWeight: 600,
|
||||
},
|
||||
'&.Mui-required': {
|
||||
'&::after': {
|
||||
color: '#D32F2F',
|
||||
},
|
||||
},
|
||||
},
|
||||
'& .MuiFormHelperText-root': {
|
||||
color: '#5D4037',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
marginTop: '4px',
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
'&::placeholder': {
|
||||
color: '#8D6E63',
|
||||
opacity: 0.7,
|
||||
},
|
||||
},
|
||||
'& .MuiSelect-select': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
'& .MuiMenuItem-root': {
|
||||
color: '#1A1611',
|
||||
'&:hover': {
|
||||
backgroundColor: '#F7F3E9',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: '#E8E5D3',
|
||||
'&:hover': {
|
||||
backgroundColor: '#E8E5D3',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const paperStyles = {
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
};
|
||||
|
||||
export const accordionStyles = {
|
||||
mb: 2,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for accordions
|
||||
'&:before': {
|
||||
display: 'none', // Remove default border
|
||||
},
|
||||
};
|
||||
|
||||
export const cardStyles = {
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for cards
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.08)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Type definitions for Story Setup components
|
||||
|
||||
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
|
||||
|
||||
export interface StorySetupProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export interface CustomValuesState {
|
||||
customWritingStyles: string[];
|
||||
customStoryTones: string[];
|
||||
customNarrativePOVs: string[];
|
||||
customAudienceAgeGroups: string[];
|
||||
customContentRatings: string[];
|
||||
customEndingPreferences: string[];
|
||||
}
|
||||
|
||||
export interface CustomValuesSetters {
|
||||
setCustomWritingStyles: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomStoryTones: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomNarrativePOVs: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomAudienceAgeGroups: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomContentRatings: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomEndingPreferences: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export interface SectionProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
customValues: CustomValuesState;
|
||||
textFieldStyles: any;
|
||||
}
|
||||
|
||||
292
frontend/src/components/StoryWriter/Phases/StoryWriting.tsx
Normal file
292
frontend/src/components/StoryWriter/Phases/StoryWriting.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../api/client';
|
||||
|
||||
interface StoryWritingProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
// Helper function to check if story is short
|
||||
const isShortStory = (storyLength: string | null | undefined): boolean => {
|
||||
if (!storyLength) return false;
|
||||
const storyLengthLower = storyLength.toLowerCase();
|
||||
return storyLengthLower.includes('short') || storyLengthLower.includes('1000');
|
||||
};
|
||||
|
||||
const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerateStart = async () => {
|
||||
if (!state.premise || (!state.outline && !state.outlineScenes)) {
|
||||
setError('Please generate a premise and outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
// Use structured scenes if available, otherwise use text outline
|
||||
const outline = state.isOutlineStructured && state.outlineScenes
|
||||
? state.outlineScenes
|
||||
: (state.outline || '');
|
||||
|
||||
const response = await storyWriterApi.generateStoryStart(
|
||||
state.premise,
|
||||
outline,
|
||||
request
|
||||
);
|
||||
|
||||
if (response.success && response.story) {
|
||||
state.setStoryContent(response.story);
|
||||
state.setIsComplete(response.is_complete);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.story || 'Failed to generate story');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Story start generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StoryWriting: Detected subscription error, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StoryWriting: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StoryWriting: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate story';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!state.premise || (!state.outline && !state.outlineScenes) || !state.storyContent) {
|
||||
setError('Please generate story content first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsContinuing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
// Use structured scenes if available, otherwise use text outline
|
||||
const outline = state.isOutlineStructured && state.outlineScenes
|
||||
? state.outlineScenes
|
||||
: (state.outline || '');
|
||||
|
||||
const continueRequest = {
|
||||
...request,
|
||||
premise: state.premise,
|
||||
outline: outline,
|
||||
story_text: state.storyContent,
|
||||
};
|
||||
|
||||
const response = await storyWriterApi.continueStory(continueRequest);
|
||||
|
||||
if (response.success && response.continuation) {
|
||||
// Check if continuation is IAMDONE marker
|
||||
const isDone = response.is_complete || /IAMDONE/i.test(response.continuation);
|
||||
|
||||
// Strip IAMDONE marker if present for cleaner display
|
||||
const cleanContinuation = response.continuation.replace(/IAMDONE/gi, '').trim();
|
||||
|
||||
// Only append continuation if it's not just IAMDONE or empty
|
||||
if (cleanContinuation) {
|
||||
state.setStoryContent((state.storyContent || '') + '\n\n' + cleanContinuation);
|
||||
}
|
||||
|
||||
// Set completion status
|
||||
state.setIsComplete(isDone);
|
||||
|
||||
// If story is complete, show success message
|
||||
if (isDone) {
|
||||
console.log('Story is complete. Word count target reached.');
|
||||
}
|
||||
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.continuation || 'Failed to continue story');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Story continuation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StoryWriting: Detected subscription error in continuation, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StoryWriting: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsContinuing(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StoryWriting: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to continue story';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsContinuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueToExport = () => {
|
||||
if (state.storyContent && state.isComplete) {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Story Writing
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: '#5D4037' }}>
|
||||
Generate your story content. You can generate the starting section and continue writing until the story is complete.
|
||||
</Typography>
|
||||
{state.storyContent && (
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037', fontStyle: 'italic' }}>
|
||||
Current word count: {state.storyContent.split(/\s+/).filter(word => word.length > 0).length} words
|
||||
{state.storyLength && (
|
||||
<> (Target: {state.storyLength.includes('1000') ? '>1000' : state.storyLength.includes('5000') ? '>5000' : '>10000'} words)</>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(!state.premise || (!state.outline && !state.outlineScenes)) && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
Please generate a premise and outline first.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state.storyContent ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={20}
|
||||
value={state.storyContent}
|
||||
onChange={(e) => state.setStoryContent(e.target.value)}
|
||||
label="Story Content"
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Only show Continue Writing button for medium/long stories that are not complete */}
|
||||
{!state.isComplete && !isShortStory(state.storyLength) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleContinue}
|
||||
disabled={isContinuing || !state.storyContent}
|
||||
>
|
||||
{isContinuing ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Continuing...
|
||||
</>
|
||||
) : (
|
||||
'Continue Writing'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Show completion message if story is complete */}
|
||||
{state.isComplete && (
|
||||
<Alert severity="success" sx={{ flex: 1, minWidth: '200px' }}>
|
||||
Story is complete! You can proceed to export.
|
||||
</Alert>
|
||||
)}
|
||||
{/* Show info message for short stories that are not complete yet */}
|
||||
{!state.isComplete && isShortStory(state.storyLength) && (
|
||||
<Alert severity="info" sx={{ flex: 1, minWidth: '200px' }}>
|
||||
Short stories are generated in one call. If the story is incomplete, please regenerate it.
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinueToExport}
|
||||
disabled={!state.storyContent || !state.isComplete}
|
||||
>
|
||||
Continue to Export
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{state.premise && (state.outline || state.outlineScenes)
|
||||
? 'Click "Generate Story" to start writing your story.'
|
||||
: 'Please generate a premise and outline first.'}
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleGenerateStart}
|
||||
disabled={isGenerating || !state.premise || (!state.outline && !state.outlineScenes)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
'Generate Story'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryWriting;
|
||||
Reference in New Issue
Block a user