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:
ajaysi
2025-11-13 16:14:26 +05:30
parent 7191c7e7f0
commit 3b9356e2c8
124 changed files with 20055 additions and 1208 deletions

View 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;

View 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;

View 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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
),
}}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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.",
];

View 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;

View File

@@ -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)',
};

View File

@@ -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;
}

View 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;