AI Story Writer Backend Migration Complete, Frontend UI Components Added
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Typography, useTheme } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Container, Typography, useTheme, Dialog, DialogTitle, DialogContent, IconButton } from '@mui/material';
|
||||
import { useStoryWriterState } from '../../hooks/useStoryWriterState';
|
||||
import { useStoryWriterPhaseNavigation } from '../../hooks/useStoryWriterPhaseNavigation';
|
||||
import StorySetup from './Phases/StorySetup';
|
||||
@@ -7,6 +7,12 @@ import StoryOutline from './Phases/StoryOutline';
|
||||
import StoryWriting from './Phases/StoryWriting';
|
||||
import StoryExport from './Phases/StoryExport';
|
||||
import PhaseNavigation from './PhaseNavigation';
|
||||
import { MultimediaToolbar } from './components/MultimediaToolbar';
|
||||
import { storyWriterApi } from '../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../api/client';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { MultimediaSection } from './components/MultimediaSection';
|
||||
import StoryWriterLanding from './StoryWriterLanding';
|
||||
|
||||
export const StoryWriter: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
@@ -14,6 +20,15 @@ export const StoryWriter: React.FC = () => {
|
||||
// State management
|
||||
const state = useStoryWriterState();
|
||||
|
||||
// Multimedia generation state
|
||||
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
|
||||
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||||
const [isMultimediaDialogOpen, setIsMultimediaDialogOpen] = useState(false);
|
||||
const [landingDismissed, setLandingDismissed] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.localStorage.getItem('storywriter:landingDismissed') === 'true';
|
||||
});
|
||||
|
||||
// Phase navigation
|
||||
const {
|
||||
phases,
|
||||
@@ -30,12 +45,138 @@ export const StoryWriter: React.FC = () => {
|
||||
const handleReset = () => {
|
||||
// Reset story state (this also clears localStorage)
|
||||
state.resetState();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem('storywriter:landingDismissed');
|
||||
}
|
||||
// Simplest approach: reload the page to ensure a clean slate
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMultimediaDialog = () => {
|
||||
setIsMultimediaDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseMultimediaDialog = () => {
|
||||
setIsMultimediaDialogOpen(false);
|
||||
};
|
||||
|
||||
// Audio generation handler
|
||||
const handleGenerateAudio = async () => {
|
||||
if (!state.enableNarration) {
|
||||
return;
|
||||
}
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAudio(true);
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
await triggerSubscriptionError(err);
|
||||
}
|
||||
console.error('Audio generation failed:', err);
|
||||
} finally {
|
||||
setIsGeneratingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Video generation handler
|
||||
const handleGenerateVideo = async () => {
|
||||
if (!state.enableVideoNarration) {
|
||||
return;
|
||||
}
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.sceneImages || state.sceneImages.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.sceneAudio || state.sceneAudio.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVideo(true);
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
|
||||
throw new Error('Number of images and audio files must match number of scenes');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
await triggerSubscriptionError(err);
|
||||
}
|
||||
console.error('Video generation failed:', err);
|
||||
} finally {
|
||||
setIsGeneratingVideo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasStoryProgress = Boolean(state.premise || state.outline || state.storyContent);
|
||||
const showLanding = !landingDismissed && !hasStoryProgress;
|
||||
|
||||
const handleLandingStart = () => {
|
||||
setLandingDismissed(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('storywriter:landingDismissed', 'true');
|
||||
}
|
||||
navigateToPhase('setup');
|
||||
};
|
||||
|
||||
// Render phase content
|
||||
const renderPhaseContent = () => {
|
||||
switch (currentPhase) {
|
||||
@@ -52,6 +193,22 @@ export const StoryWriter: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (showLanding) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
||||
padding: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl">
|
||||
<StoryWriterLanding onStart={handleLandingStart} />
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -90,29 +247,62 @@ export const StoryWriter: React.FC = () => {
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
{/* Header with Phase Navigation and Multimedia Toolbar */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h3" component="h1" gutterBottom sx={{ color: 'white' }}>
|
||||
Story Writer
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
<Typography variant="body1" sx={{ color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||
Create compelling stories with AI assistance
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Phase Navigation */}
|
||||
{/* Compact Phase Navigation */}
|
||||
<Box sx={{ flex: '1 1 auto', minWidth: { xs: '100%', md: '600px' }, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<PhaseNavigation
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={navigateToPhase}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</Box>
|
||||
{/* Multimedia Toolbar */}
|
||||
<MultimediaToolbar
|
||||
state={state}
|
||||
onGenerateAudio={handleGenerateAudio}
|
||||
onGenerateVideo={handleGenerateVideo}
|
||||
isGeneratingAudio={isGeneratingAudio}
|
||||
isGeneratingVideo={isGeneratingVideo}
|
||||
onOpenPanel={(_section) => handleOpenMultimediaDialog()}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Phase Content */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
{renderPhaseContent()}
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Dialog
|
||||
open={isMultimediaDialogOpen}
|
||||
onClose={handleCloseMultimediaDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
Multimedia Controls
|
||||
<IconButton size="small" onClick={handleCloseMultimediaDialog}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<MultimediaSection state={state} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user