AI Story Writer Backend Migration Complete, Frontend UI Components Added
This commit is contained in:
@@ -63,6 +63,25 @@ export interface SchedulerDashboardData {
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface TaskFailurePattern {
|
||||
consecutive_failures: number;
|
||||
recent_failures: number;
|
||||
failure_reason: string;
|
||||
last_failure_time: string | null;
|
||||
error_patterns: string[];
|
||||
}
|
||||
|
||||
export interface TaskNeedingIntervention {
|
||||
task_id: number;
|
||||
task_type: string;
|
||||
user_id: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
failure_pattern: TaskFailurePattern;
|
||||
failure_reason: string | null;
|
||||
last_failure: string | null;
|
||||
}
|
||||
|
||||
export interface TaskInfo {
|
||||
id: number;
|
||||
task_title: string;
|
||||
@@ -258,3 +277,29 @@ export const getRecentSchedulerLogs = async (): Promise<ExecutionLogsResponse> =
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tasks that require manual intervention for a user.
|
||||
*/
|
||||
export const getTasksNeedingIntervention = async (userId: string): Promise<TaskNeedingIntervention[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
tasks: TaskNeedingIntervention[];
|
||||
count: number;
|
||||
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Failed to fetch tasks needing intervention');
|
||||
}
|
||||
|
||||
return response.data.tasks || [];
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching tasks needing intervention:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch tasks needing intervention'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { SubscriptionGuard } from '../SubscriptionGuard';
|
||||
|
||||
// Shared components
|
||||
import DashboardHeader from '../shared/DashboardHeader';
|
||||
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
|
||||
import LoadingSkeleton from '../shared/LoadingSkeleton';
|
||||
import ErrorDisplay from '../shared/ErrorDisplay';
|
||||
import ContentLifecyclePillars from './ContentLifecyclePillars';
|
||||
@@ -285,7 +284,6 @@ const MainDashboard: React.FC = () => {
|
||||
title="Alwrity Content Hub"
|
||||
subtitle=""
|
||||
statusChips={[]}
|
||||
rightContent={<SystemStatusIndicator />}
|
||||
customIcon={AskAlwrityIcon}
|
||||
workflowControls={{
|
||||
onStartWorkflow: handleStartWorkflow,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { TerminalTypography, terminalColors } from './terminalTheme';
|
||||
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../../api/schedulerDashboard';
|
||||
|
||||
const InterventionContainer = styled(Box)({
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
@@ -76,23 +77,6 @@ const StatusChip = styled(Chip)(({ severity }: { severity: 'error' | 'warning' }
|
||||
fontWeight: 'bold',
|
||||
}));
|
||||
|
||||
interface TaskNeedingIntervention {
|
||||
task_id: number;
|
||||
task_type: string;
|
||||
user_id: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
failure_pattern: {
|
||||
consecutive_failures: number;
|
||||
recent_failures: number;
|
||||
failure_reason: string;
|
||||
last_failure_time: string | null;
|
||||
error_patterns: string[];
|
||||
};
|
||||
failure_reason: string | null;
|
||||
last_failure: string | null;
|
||||
}
|
||||
|
||||
interface TasksNeedingInterventionProps {
|
||||
userId: string;
|
||||
}
|
||||
@@ -106,15 +90,8 @@ const TasksNeedingIntervention: React.FC<TasksNeedingInterventionProps> = ({ use
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
tasks: TaskNeedingIntervention[];
|
||||
count: number;
|
||||
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setTasks(response.data.tasks || []);
|
||||
}
|
||||
const fetchedTasks = await getTasksNeedingIntervention(userId);
|
||||
setTasks(fetchedTasks || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks needing intervention:', error);
|
||||
} finally {
|
||||
|
||||
@@ -27,40 +27,64 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{onReset && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||
<Box sx={{ position: 'absolute', top: -8, right: -8, zIndex: 10 }}>
|
||||
<Tooltip title="Restart Story (Clear all data and start from beginning)">
|
||||
<IconButton
|
||||
onClick={handleReset}
|
||||
sx={{
|
||||
color: '#5D4037',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: '#E8E5D3',
|
||||
color: '#1A1611',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon />
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
<Stepper activeStep={activeStep} alternativeLabel>
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
alternativeLabel
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
'& .MuiStepLabel-label': {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
'&.Mui-active': {
|
||||
color: 'white',
|
||||
},
|
||||
'&.Mui-completed': {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
},
|
||||
'& .MuiStepLabel-iconContainer': {
|
||||
'& .MuiSvgIcon-root': {
|
||||
color: 'rgba(255, 255, 255, 0.3)',
|
||||
'&.Mui-active': {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
'&.Mui-completed': {
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{phases.map((phase) => (
|
||||
<Step key={phase.id} completed={phase.completed} disabled={phase.disabled}>
|
||||
<StepButton
|
||||
onClick={() => !phase.disabled && onPhaseClick(phase.id)}
|
||||
disabled={phase.disabled}
|
||||
sx={{
|
||||
padding: '8px 4px',
|
||||
'& .MuiStepLabel-root': {
|
||||
cursor: phase.disabled ? 'not-allowed' : 'pointer',
|
||||
},
|
||||
@@ -70,22 +94,33 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
StepIconComponent={() => (
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: phase.current
|
||||
? 'primary.main'
|
||||
? 'rgba(255, 255, 255, 0.9)'
|
||||
: phase.completed
|
||||
? 'success.main'
|
||||
? 'rgba(76, 175, 80, 0.9)'
|
||||
: phase.disabled
|
||||
? 'grey.300'
|
||||
: 'grey.200',
|
||||
color: phase.current || phase.completed ? 'white' : 'text.secondary',
|
||||
fontSize: '1.2rem',
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(255, 255, 255, 0.3)',
|
||||
color: phase.current
|
||||
? '#667eea'
|
||||
: phase.completed
|
||||
? 'white'
|
||||
: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: '1rem',
|
||||
fontWeight: phase.current ? 600 : 400,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': !phase.disabled ? {
|
||||
backgroundColor: phase.current
|
||||
? 'rgba(255, 255, 255, 1)'
|
||||
: 'rgba(255, 255, 255, 0.4)',
|
||||
transform: 'scale(1.05)',
|
||||
} : {},
|
||||
}}
|
||||
>
|
||||
{phase.icon}
|
||||
@@ -93,29 +128,26 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: phase.current ? 600 : 400,
|
||||
color: phase.disabled ? '#9E9E9E' : '#2C2416', // Dark brown text
|
||||
fontSize: '0.75rem',
|
||||
color: phase.disabled
|
||||
? 'rgba(255, 255, 255, 0.4)'
|
||||
: phase.current
|
||||
? 'white'
|
||||
: 'rgba(255, 255, 255, 0.8)',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{phase.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: phase.disabled ? '#9E9E9E' : '#5D4037', // Medium brown for secondary text
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{phase.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
</StepButton>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
};
|
||||
|
||||
const handleGenerateVideo = async () => {
|
||||
if (!state.enableVideoNarration) {
|
||||
setError('Story video generation is disabled in Story Setup.');
|
||||
return;
|
||||
}
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
@@ -270,6 +274,7 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
|
||||
{/* Video Generation */}
|
||||
{state.isOutlineStructured && state.outlineScenes && (
|
||||
state.enableVideoNarration ? (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Video Generation
|
||||
@@ -338,6 +343,11 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="info" sx={{ mb: 4 }}>
|
||||
Story video generation is disabled in Story Setup. Enable it to create narrated videos.
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/material';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
|
||||
interface AudioControlsPanelProps {
|
||||
enabled: boolean;
|
||||
regenerating: boolean;
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
const AudioControlsPanel: React.FC<AudioControlsPanelProps> = ({
|
||||
enabled,
|
||||
regenerating,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ mt: 1.5, p: 2, border: '1px dashed rgba(120,90,60,0.35)', borderRadius: 1.5, backgroundColor: 'rgba(255,255,255,0.6)' }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335', display: 'block', mb: 1 }}>
|
||||
Audio controls (uses Setup settings)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={regenerating ? <CircularProgress size={16} /> : <VolumeUpIcon />}
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating || !enabled}
|
||||
>
|
||||
{regenerating ? 'Regenerating...' : 'Regenerate Audio (Scene)'}
|
||||
</Button>
|
||||
</Box>
|
||||
{!enabled && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#a37b55' }}>
|
||||
Enable Narration in Story Setup to generate audio.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioControlsPanel;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
|
||||
|
||||
interface AudioScriptModalProps {
|
||||
open: boolean;
|
||||
sceneNumber: number;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
// audio settings
|
||||
audioProvider: string;
|
||||
audioLang: string;
|
||||
audioSlow: boolean;
|
||||
audioRate: number;
|
||||
onChangeProvider: (v: string) => void;
|
||||
onChangeLang: (v: string) => void;
|
||||
onChangeSlow: (v: boolean) => void;
|
||||
onChangeRate: (v: number) => void;
|
||||
audioUrl?: string | null;
|
||||
}
|
||||
|
||||
const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
|
||||
open, sceneNumber, value, onChange, onClose, onSave,
|
||||
audioProvider, audioLang, audioSlow, audioRate,
|
||||
onChangeProvider, onChangeLang, onChangeSlow, onChangeRate,
|
||||
audioUrl,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Audio Narration Script (Scene {sceneNumber})</DialogTitle>
|
||||
<DialogContent dividers sx={{ color: '#2C2416' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
'& .MuiFormLabel-root': { color: '#6b5846' },
|
||||
'& .MuiInputBase-root': { color: '#2C2416' },
|
||||
}}
|
||||
>
|
||||
{audioUrl ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.03)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<audio controls src={audioUrl || undefined} style={{ width: '100%' }}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</Box>
|
||||
) : null}
|
||||
<TextField
|
||||
label="Audio Narration"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
multiline
|
||||
minRows={6}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
<TextField
|
||||
select
|
||||
label="Audio Provider"
|
||||
value={audioProvider}
|
||||
onChange={(e) => onChangeProvider(e.target.value)}
|
||||
SelectProps={{ native: true }}
|
||||
>
|
||||
<option value="gtts">gTTS</option>
|
||||
<option value="pyttsx3">pyttsx3</option>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Language (e.g., en, hi)"
|
||||
value={audioLang}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Slow (gTTS)"
|
||||
value={audioSlow ? 'true' : 'false'}
|
||||
onChange={(e) => onChangeSlow(e.target.value === 'true')}
|
||||
SelectProps={{ native: true }}
|
||||
>
|
||||
<option value="false">Normal</option>
|
||||
<option value="true">Slow</option>
|
||||
</TextField>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Rate (pyttsx3)"
|
||||
value={audioRate}
|
||||
onChange={(e) => onChangeRate(Number(e.target.value))}
|
||||
inputProps={{ min: 50, max: 300, step: 10 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioScriptModal;
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Tooltip, Chip } from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import OutlineHoverActions from './OutlineHoverActions';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
|
||||
import { leftPageVariants, rightPageVariants } from './pageVariants';
|
||||
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
interface ImageSettings {
|
||||
provider?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
model?: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface BookPagesProps {
|
||||
currentScene: StoryScene | null;
|
||||
currentSceneIndex: number;
|
||||
scenesLength: number;
|
||||
canGoPrev: boolean;
|
||||
canGoNext: boolean;
|
||||
pageDirection: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
|
||||
imageUrl: string | null;
|
||||
onImageError: () => void;
|
||||
|
||||
narrationEnabled: boolean;
|
||||
audioUrl: string | null;
|
||||
onOpenImageModal: () => void;
|
||||
onOpenAudioModal: () => void;
|
||||
onOpenCharactersModal: () => void;
|
||||
onOpenKeyEventsModal: () => void;
|
||||
onOpenTitleModal: () => void;
|
||||
onOpenEditModal: () => void;
|
||||
}
|
||||
|
||||
const BookPages: React.FC<BookPagesProps> = ({
|
||||
currentScene,
|
||||
currentSceneIndex,
|
||||
scenesLength,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
pageDirection,
|
||||
onPrev,
|
||||
onNext,
|
||||
imageUrl,
|
||||
onImageError,
|
||||
narrationEnabled,
|
||||
onOpenImageModal,
|
||||
onOpenAudioModal,
|
||||
audioUrl,
|
||||
onOpenCharactersModal,
|
||||
onOpenKeyEventsModal,
|
||||
onOpenTitleModal,
|
||||
onOpenEditModal,
|
||||
}) => {
|
||||
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<Box
|
||||
className="tw-shadow-book tw-rounded-book"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: { xs: '100%', lg: '90vw' },
|
||||
maxWidth: '1800px',
|
||||
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}>
|
||||
<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={onPrev}
|
||||
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 {currentSceneNumber} of {scenesLength}
|
||||
</Typography>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 1, '&:hover .title-edit': { opacity: 1, pointerEvents: 'auto' } }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
mt: 1,
|
||||
color: '#2C2416',
|
||||
fontFamily: `'Playfair Display', serif`,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
pr: 2,
|
||||
}}
|
||||
>
|
||||
{currentScene?.title}
|
||||
</Typography>
|
||||
<Box
|
||||
className="title-edit"
|
||||
role="button"
|
||||
aria-label="Edit title"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenTitleModal(); }}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 6px 12px rgba(127,90,240,0.25)',
|
||||
cursor: 'pointer',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon fontSize="small" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
overflowY: 'auto',
|
||||
mt: 3,
|
||||
display: 'grid',
|
||||
gridTemplateRows: imageUrl ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
|
||||
alignContent: 'start',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', '&:hover .left-image-actions': { opacity: 1, pointerEvents: 'auto' } }}>
|
||||
{imageUrl ? (
|
||||
<>
|
||||
{/* Removed 'Scene Illustration' heading for cleaner look */}
|
||||
<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={imageUrl}
|
||||
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
minHeight: '300px',
|
||||
maxHeight: '500px',
|
||||
}}
|
||||
onError={onImageError}
|
||||
/>
|
||||
<Box
|
||||
className="left-image-actions"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.2s ease',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Edit scene image prompt">
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Edit scene image"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenImageModal(); }}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
|
||||
boxShadow: '0 8px 16px rgba(127,90,240,0.3)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</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 sx={{ mt: 1 }}>
|
||||
<Tooltip title="Edit scene image prompt">
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Edit scene image"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenImageModal(); }}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
|
||||
boxShadow: '0 8px 16px rgba(127,90,240,0.3)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Audio chip moved to right page */}
|
||||
|
||||
{/* Characters */}
|
||||
{currentScene?.character_descriptions && currentScene.character_descriptions.length > 0 && (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/* Key Events */}
|
||||
{currentScene?.key_events && currentScene.key_events.length > 0 && (<></>)}
|
||||
</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
|
||||
role="button"
|
||||
aria-label="Next scene"
|
||||
onClick={onNext}
|
||||
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',
|
||||
position: 'relative',
|
||||
'&:hover .outline-actions': { opacity: 1, pointerEvents: 'auto' },
|
||||
'&:hover .chip-actions': { opacity: 1, pointerEvents: 'auto' },
|
||||
}}
|
||||
>
|
||||
<OutlineHoverActions onEdit={onOpenEditModal} onImprove={onOpenEditModal} />
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', pt: { xs: 1, md: 2 } }}>
|
||||
<Box className="chip-actions" sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5, opacity: 0, pointerEvents: 'none', transition: 'opacity 0.2s ease' }}>
|
||||
<Chip
|
||||
size="medium"
|
||||
variant="filled"
|
||||
icon={<TipsAndUpdatesIcon sx={{ color: '#2CB67D !important' }} />}
|
||||
label={audioUrl ? 'Audio' : 'Audio'}
|
||||
onClick={(e) => { e.stopPropagation(); onOpenAudioModal(); }}
|
||||
sx={{
|
||||
px: 1.6,
|
||||
py: 0.6,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
color: '#2C2416',
|
||||
background: 'linear-gradient(135deg, #fffefc 0%, #f7efe1 100%)',
|
||||
boxShadow: '0 8px 20px rgba(93,59,36,0.15)',
|
||||
border: '1px solid rgba(120,90,60,0.25)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
{currentScene?.character_descriptions && currentScene.character_descriptions.length > 0 && (
|
||||
<Chip
|
||||
size="medium"
|
||||
variant="filled"
|
||||
label={`Characters (${currentScene.character_descriptions.length})`}
|
||||
onClick={(e) => { e.stopPropagation(); onOpenCharactersModal(); }}
|
||||
sx={{
|
||||
px: 1.6,
|
||||
py: 0.6,
|
||||
fontWeight: 700,
|
||||
color: '#2C2416',
|
||||
background: 'linear-gradient(135deg, #fffefc 0%, #f5ecd8 100%)',
|
||||
boxShadow: '0 8px 20px rgba(93,59,36,0.15)',
|
||||
border: '1px solid rgba(120,90,60,0.25)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentScene?.key_events && currentScene.key_events.length > 0 && (
|
||||
<Chip
|
||||
size="medium"
|
||||
variant="filled"
|
||||
label={`Key events (${currentScene.key_events.length})`}
|
||||
onClick={(e) => { e.stopPropagation(); onOpenKeyEventsModal(); }}
|
||||
sx={{
|
||||
px: 1.6,
|
||||
py: 0.6,
|
||||
fontWeight: 700,
|
||||
color: '#2C2416',
|
||||
background: 'linear-gradient(135deg, #fffefc 0%, #f5ecd8 100%)',
|
||||
boxShadow: '0 8px 20px rgba(93,59,36,0.15)',
|
||||
border: '1px solid rgba(120,90,60,0.25)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookPages;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Button, Chip, Typography } from '@mui/material';
|
||||
|
||||
interface CharactersModalProps {
|
||||
open: boolean;
|
||||
sceneNumber: number;
|
||||
characters: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CharactersModal: React.FC<CharactersModalProps> = ({ open, sceneNumber, characters, onClose }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Characters (Scene {sceneNumber})</DialogTitle>
|
||||
<DialogContent dividers sx={{ color: '#2C2416' }}>
|
||||
{characters && characters.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.25 }}>
|
||||
{characters.map((c, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={c}
|
||||
variant="outlined"
|
||||
sx={{ bgcolor: '#fff', color: '#2C2416', borderColor: 'rgba(0,0,0,0.15)' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2">No characters provided for this scene.</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharactersModal;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Paper, TextField, Typography } from '@mui/material';
|
||||
|
||||
interface EditSectionModalProps {
|
||||
open: boolean;
|
||||
sceneNumber: number;
|
||||
editText: string;
|
||||
onChangeEditText: (val: string) => void;
|
||||
aiFeedback: string;
|
||||
onChangeAiFeedback: (val: string) => void;
|
||||
aiLoading: boolean;
|
||||
onGenerateSuggestions: () => void;
|
||||
suggestions: string[];
|
||||
onPickSuggestion: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const EditSectionModal: React.FC<EditSectionModalProps> = ({
|
||||
open,
|
||||
sceneNumber,
|
||||
editText,
|
||||
onChangeEditText,
|
||||
aiFeedback,
|
||||
onChangeAiFeedback,
|
||||
aiLoading,
|
||||
onGenerateSuggestions,
|
||||
suggestions,
|
||||
onPickSuggestion,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Section (Scene {sceneNumber})</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Section Text"
|
||||
value={editText}
|
||||
onChange={(e) => onChangeEditText(e.target.value)}
|
||||
multiline
|
||||
minRows={6}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Tell Alwrity what to improve (optional)"
|
||||
value={aiFeedback}
|
||||
onChange={(e) => onChangeAiFeedback(e.target.value)}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
helperText="Describe desired changes (tone, pacing, details). Generate to get 2 suggestions."
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button variant="outlined" onClick={onGenerateSuggestions} disabled={aiLoading}>
|
||||
{aiLoading ? 'Generating...' : 'Generate AI Suggestions'}
|
||||
</Button>
|
||||
</Box>
|
||||
{suggestions.length > 0 && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
{suggestions.map((s, i) => (
|
||||
<Paper key={i} sx={{ p: 2, border: '1px solid rgba(120,90,60,0.2)' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Suggestion {i + 1}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{s}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="small" variant="text" onClick={() => onPickSuggestion(i)}>
|
||||
Use this
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>
|
||||
Save & Update
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditSectionModal;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
|
||||
|
||||
interface ImageEditModalProps {
|
||||
open: boolean;
|
||||
sceneNumber: number;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const ImageEditModal: React.FC<ImageEditModalProps> = ({ open, sceneNumber, value, onChange, onClose, onSave }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Scene Illustration Prompt (Scene {sceneNumber})</DialogTitle>
|
||||
<DialogContent dividers sx={{ color: '#2C2416' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
'& .MuiFormLabel-root': { color: '#6b5846' },
|
||||
'& .MuiInputBase-root': { color: '#2C2416' },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Image Prompt"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
multiline
|
||||
minRows={5}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageEditModal;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Button, Typography } from '@mui/material';
|
||||
|
||||
interface KeyEventsModalProps {
|
||||
open: boolean;
|
||||
sceneNumber: number;
|
||||
events: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const KeyEventsModal: React.FC<KeyEventsModalProps> = ({ open, sceneNumber, events, onClose }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Key Events (Scene {sceneNumber})</DialogTitle>
|
||||
<DialogContent dividers sx={{ color: '#2C2416' }}>
|
||||
{events && events.length > 0 ? (
|
||||
<Box component="ul" sx={{ pl: 2, mb: 0 }}>
|
||||
{events.map((e, idx) => (
|
||||
<li key={idx}>
|
||||
<Typography variant="body2">{e}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2">No key events provided for this scene.</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyEventsModal;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, CircularProgress } from '@mui/material';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import { outlineActionButtonSx, primaryButtonSx } from './buttonStyles';
|
||||
|
||||
interface OutlineActionsBarProps {
|
||||
isGenerating: boolean;
|
||||
canRegenerateOutline: boolean;
|
||||
onRegenerateOutline: () => void;
|
||||
|
||||
showMediaActions: boolean;
|
||||
isGeneratingImages: boolean;
|
||||
isGeneratingAudio: boolean;
|
||||
illustrationEnabled: boolean;
|
||||
narrationEnabled: boolean;
|
||||
onGenerateImages: () => void;
|
||||
onGenerateAudio: () => void;
|
||||
|
||||
canContinue: boolean;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
const OutlineActionsBar: React.FC<OutlineActionsBarProps> = ({
|
||||
isGenerating,
|
||||
canRegenerateOutline,
|
||||
onRegenerateOutline,
|
||||
showMediaActions,
|
||||
isGeneratingImages,
|
||||
isGeneratingAudio,
|
||||
illustrationEnabled,
|
||||
narrationEnabled,
|
||||
onGenerateImages,
|
||||
onGenerateAudio,
|
||||
canContinue,
|
||||
onContinue,
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onRegenerateOutline}
|
||||
disabled={isGenerating || !canRegenerateOutline}
|
||||
sx={outlineActionButtonSx}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
'Regenerate Outline'
|
||||
)}
|
||||
</Button>
|
||||
{showMediaActions && (
|
||||
<>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ImageIcon />}
|
||||
onClick={onGenerateImages}
|
||||
disabled={isGeneratingImages || !illustrationEnabled}
|
||||
sx={outlineActionButtonSx}
|
||||
title={!illustrationEnabled ? 'Enable Illustration in Story Setup to generate images.' : undefined}
|
||||
>
|
||||
{isGeneratingImages ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Images...
|
||||
</>
|
||||
) : (
|
||||
'Generate Images'
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<VolumeUpIcon />}
|
||||
onClick={onGenerateAudio}
|
||||
disabled={isGeneratingAudio || !narrationEnabled}
|
||||
sx={outlineActionButtonSx}
|
||||
title={!narrationEnabled ? 'Enable Narration in Story Setup to generate audio.' : undefined}
|
||||
>
|
||||
{isGeneratingAudio ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Audio...
|
||||
</>
|
||||
) : (
|
||||
'Generate Audio'
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue}
|
||||
sx={primaryButtonSx}
|
||||
>
|
||||
Continue to Writing
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineActionsBar;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Box, Tooltip } from '@mui/material';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
|
||||
|
||||
interface OutlineHoverActionsProps {
|
||||
onEdit: () => void;
|
||||
onImprove: () => void;
|
||||
}
|
||||
|
||||
const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({ onEdit, onImprove }) => {
|
||||
return (
|
||||
<Box
|
||||
className="outline-actions"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Edit this section">
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Edit section"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
|
||||
boxShadow: '0 8px 16px rgba(127,90,240,0.3)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip title="Improve with AI (2 suggestions)">
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="AI improve section"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImprove();
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #5d3b24 0%, #a36c3b 45%, #f1c27d 100%)',
|
||||
boxShadow: '0 8px 16px rgba(93,59,36,0.35)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<TipsAndUpdatesIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineHoverActions;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/material';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
|
||||
interface SceneGenerationPanelProps {
|
||||
provider?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
model?: string | null;
|
||||
enabled: boolean;
|
||||
regenerating: boolean;
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
const SceneGenerationPanel: React.FC<SceneGenerationPanelProps> = ({
|
||||
provider,
|
||||
width,
|
||||
height,
|
||||
model,
|
||||
enabled,
|
||||
regenerating,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ mt: 1.5, p: 2, border: '1px dashed rgba(120,90,60,0.35)', borderRadius: 1.5, backgroundColor: 'rgba(255,255,255,0.6)' }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335', display: 'block', mb: 1 }}>
|
||||
Scene generation controls (uses Setup settings)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037', display: 'block', mb: 1 }}>
|
||||
Provider: {provider || 'Auto'} · Size: {width}x{height}{model ? ` · Model: ${model}` : ''}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={regenerating ? <CircularProgress size={16} /> : <ImageIcon />}
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating || !enabled}
|
||||
>
|
||||
{regenerating ? 'Regenerating...' : 'Regenerate Image (Scene)'}
|
||||
</Button>
|
||||
{!enabled && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#a37b55' }}>
|
||||
Enable Illustration in Story Setup to generate images.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SceneGenerationPanel;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
|
||||
|
||||
interface TitleEditModalProps {
|
||||
open: boolean;
|
||||
sceneNumber: number;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const TitleEditModal: React.FC<TitleEditModalProps> = ({ open, sceneNumber, value, onChange, onClose, onSave }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Scene Title (Scene {sceneNumber})</DialogTitle>
|
||||
<DialogContent dividers sx={{ color: '#2C2416' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleEditModal;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export const outlineActionButtonSx = {
|
||||
textTransform: 'none',
|
||||
borderRadius: '999px',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1.2,
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(59, 34, 18, 0.35)',
|
||||
color: '#3B2618',
|
||||
backgroundColor: 'rgba(255, 249, 239, 0.95)',
|
||||
boxShadow: '0 14px 26px rgba(26, 22, 17, 0.12)',
|
||||
transition: 'all 0.25s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3B2618',
|
||||
boxShadow: '0 18px 32px rgba(26, 22, 17, 0.18)',
|
||||
transform: 'translateY(-2px)',
|
||||
backgroundColor: 'rgba(255, 245, 228, 0.98)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.4,
|
||||
boxShadow: 'none',
|
||||
transform: 'none',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const primaryButtonSx = {
|
||||
...outlineActionButtonSx,
|
||||
background: 'linear-gradient(125deg, #5d3b24 0%, #a36c3b 45%, #f1c27d 100%)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: '0 20px 40px rgba(93, 59, 36, 0.35)',
|
||||
'&:hover': {
|
||||
...outlineActionButtonSx['&:hover'],
|
||||
background: 'linear-gradient(125deg, #4c2f1c 0%, #8b552d 45%, #f7d9a0 100%)',
|
||||
boxShadow: '0 24px 46px rgba(93, 59, 36, 0.45)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.35,
|
||||
boxShadow: 'none',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Variants } from 'framer-motion';
|
||||
|
||||
export const easeInOut = [0.22, 0.61, 0.36, 1] as const;
|
||||
export const easeOut = [0.4, 0, 1, 1] as const;
|
||||
|
||||
export const leftPageVariants: Variants = {
|
||||
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 },
|
||||
}),
|
||||
};
|
||||
|
||||
export const rightPageVariants: Variants = {
|
||||
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 },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -2,41 +2,76 @@ import React from 'react';
|
||||
import { Grid, Typography, Box, FormControlLabel, Checkbox } from '@mui/material';
|
||||
import { SectionProps } from './types';
|
||||
|
||||
export const FeatureCheckboxesSection: React.FC<SectionProps> = ({ state }) => {
|
||||
interface FeatureCheckboxesProps {
|
||||
state: SectionProps['state'];
|
||||
layout?: 'stack' | 'inline';
|
||||
}
|
||||
|
||||
export const FeatureCheckboxesSection: React.FC<FeatureCheckboxesProps> = ({ state, layout = 'stack' }) => {
|
||||
const options = [
|
||||
{
|
||||
label: 'Explainer',
|
||||
checked: state.enableExplainer,
|
||||
onChange: (checked: boolean) => state.setEnableExplainer(checked),
|
||||
},
|
||||
{
|
||||
label: 'Illustration',
|
||||
checked: state.enableIllustration,
|
||||
onChange: (checked: boolean) => state.setEnableIllustration(checked),
|
||||
},
|
||||
{
|
||||
label: 'Narration',
|
||||
checked: state.enableNarration,
|
||||
onChange: (checked: boolean) => state.setEnableNarration(checked),
|
||||
},
|
||||
{
|
||||
label: 'Story Video',
|
||||
checked: state.enableVideoNarration,
|
||||
onChange: (checked: boolean) => state.setEnableVideoNarration(checked),
|
||||
},
|
||||
];
|
||||
|
||||
const renderCheckboxes = (direction: 'row' | 'column') => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: direction === 'row' ? 'wrap' : 'nowrap',
|
||||
flexDirection: direction === 'row' ? 'row' : 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<FormControlLabel
|
||||
key={option.label}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={option.checked}
|
||||
onChange={(e) => option.onChange(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={option.label}
|
||||
sx={{
|
||||
m: 0,
|
||||
'& .MuiFormControlLabel-label': {
|
||||
fontWeight: 600,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (layout === 'inline') {
|
||||
return renderCheckboxes('row');
|
||||
}
|
||||
|
||||
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>
|
||||
{renderCheckboxes('column')}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Slider,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { SectionProps } from './types';
|
||||
@@ -18,6 +19,27 @@ import { textFieldStyles, accordionStyles } from './styles';
|
||||
import { IMAGE_PROVIDERS, AUDIO_PROVIDERS, COMMON_IMAGE_SIZES } from './constants';
|
||||
|
||||
export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) => {
|
||||
const imageDisabled = !state.enableIllustration;
|
||||
const audioDisabled = !state.enableNarration;
|
||||
const videoDisabled = !state.enableVideoNarration;
|
||||
|
||||
const disabledStyles = (disabled: boolean) =>
|
||||
disabled
|
||||
? {
|
||||
opacity: 0.4,
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const renderHeading = (title: string, disabled: boolean) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{disabled && <Chip label="Disabled in Story Setup" size="small" />}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600 }}>
|
||||
@@ -28,13 +50,11 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
</Typography>
|
||||
|
||||
{/* Image Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<Accordion sx={accordionStyles} disabled={imageDisabled}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Image Generation Settings
|
||||
</Typography>
|
||||
{renderHeading('Image Generation Settings', imageDisabled)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<AccordionDetails sx={disabledStyles(imageDisabled)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
@@ -45,6 +65,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
onChange={(e) => state.setImageProvider(e.target.value || null)}
|
||||
helperText="Select the image generation provider. Leave as 'Auto' to use the default."
|
||||
sx={textFieldStyles}
|
||||
disabled={imageDisabled}
|
||||
>
|
||||
{IMAGE_PROVIDERS.map((provider) => (
|
||||
<MenuItem key={provider.value} value={provider.value}>
|
||||
@@ -66,6 +87,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
}}
|
||||
helperText="Select a common image size or set custom dimensions below."
|
||||
sx={textFieldStyles}
|
||||
disabled={imageDisabled}
|
||||
>
|
||||
{COMMON_IMAGE_SIZES.map((size) => (
|
||||
<MenuItem key={`${size.width}x${size.height}`} value={`${size.width}x${size.height}`}>
|
||||
@@ -84,6 +106,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
inputProps={{ min: 256, max: 2048, step: 64 }}
|
||||
helperText="Image width in pixels (256-2048)"
|
||||
sx={textFieldStyles}
|
||||
disabled={imageDisabled}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
@@ -96,6 +119,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
inputProps={{ min: 256, max: 2048, step: 64 }}
|
||||
helperText="Image height in pixels (256-2048)"
|
||||
sx={textFieldStyles}
|
||||
disabled={imageDisabled}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
@@ -107,6 +131,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
placeholder="Leave empty to use default model"
|
||||
helperText="Specific model to use for image generation (optional)"
|
||||
sx={textFieldStyles}
|
||||
disabled={imageDisabled}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -114,13 +139,11 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
</Accordion>
|
||||
|
||||
{/* Video Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<Accordion sx={accordionStyles} disabled={videoDisabled}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Video Generation Settings
|
||||
</Typography>
|
||||
{renderHeading('Video Generation Settings', videoDisabled)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<AccordionDetails sx={disabledStyles(videoDisabled)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
@@ -132,6 +155,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
inputProps={{ min: 15, max: 60, step: 1 }}
|
||||
helperText="Video frame rate (15-60 fps). Higher values create smoother video but larger files."
|
||||
sx={textFieldStyles}
|
||||
disabled={videoDisabled}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
@@ -151,6 +175,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
{ value: 2, label: '2s' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
disabled={videoDisabled}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Duration of transitions between scenes in seconds
|
||||
@@ -162,13 +187,11 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
</Accordion>
|
||||
|
||||
{/* Audio Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<Accordion sx={accordionStyles} disabled={audioDisabled}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Audio Generation Settings
|
||||
</Typography>
|
||||
{renderHeading('Audio Generation Settings', audioDisabled)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<AccordionDetails sx={disabledStyles(audioDisabled)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
@@ -179,6 +202,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
onChange={(e) => state.setAudioProvider(e.target.value)}
|
||||
helperText="Text-to-speech provider for narration"
|
||||
sx={textFieldStyles}
|
||||
disabled={audioDisabled}
|
||||
>
|
||||
{AUDIO_PROVIDERS.map((provider) => (
|
||||
<MenuItem key={provider.value} value={provider.value}>
|
||||
@@ -196,6 +220,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
placeholder="en"
|
||||
helperText="Language code for text-to-speech (e.g., 'en' for English, 'es' for Spanish)"
|
||||
sx={textFieldStyles}
|
||||
disabled={audioDisabled}
|
||||
/>
|
||||
</Grid>
|
||||
{state.audioProvider === 'gtts' && (
|
||||
@@ -205,6 +230,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
<Checkbox
|
||||
checked={state.audioSlow}
|
||||
onChange={(e) => state.setAudioSlow(e.target.checked)}
|
||||
disabled={audioDisabled}
|
||||
/>
|
||||
}
|
||||
label="Slow Speech (gTTS only)"
|
||||
@@ -229,6 +255,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
|
||||
{ value: 300, label: '300' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
disabled={audioDisabled}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Speech rate in words per minute (pyttsx3 only)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Typography, Box, Button, Alert, Grid, CircularProgress } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../../api/client';
|
||||
@@ -169,13 +170,28 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper sx={paperStyles}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
gap: 3,
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom sx={{ 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 variant="body2" sx={{ color: '#5D4037' }}>
|
||||
Configure your story parameters and premise. Fill in the required fields and click "Generate Outline" to
|
||||
continue.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FeatureCheckboxesSection state={state} layout="inline" />
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
@@ -183,15 +199,37 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
|
||||
</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
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
sx={{
|
||||
mb: 1,
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
borderRadius: '999px',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
|
||||
boxShadow: '0 12px 24px rgba(127, 90, 240, 0.3)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #6c4cd4 0%, #24a26f 100%)',
|
||||
boxShadow: '0 14px 30px rgba(127, 90, 240, 0.35)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Generate Story Setup with Alwrity AI
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Let Alwrity AI craft a cohesive persona, setting, and premise instantly.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Story Parameters Section */}
|
||||
<StoryParametersSection
|
||||
state={state}
|
||||
customValues={customValues}
|
||||
@@ -200,56 +238,61 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
|
||||
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>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box
|
||||
sx={{
|
||||
position: { md: 'sticky' },
|
||||
top: { md: 16 },
|
||||
}}
|
||||
>
|
||||
<GenerationSettingsSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
|
||||
</Box>
|
||||
</Grid>
|
||||
</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
|
||||
}
|
||||
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'
|
||||
)}
|
||||
{isGeneratingOutline ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Outline...
|
||||
</>
|
||||
) : (
|
||||
'Generate Outline'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* AI Story Setup Modal */}
|
||||
<AIStorySetupModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
state={state}
|
||||
customValuesSetters={customValuesSetters}
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../api/client';
|
||||
import { aiApiClient } from '../../../api/client';
|
||||
import { MultimediaSection } from '../components/MultimediaSection';
|
||||
|
||||
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 StoryWritingProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
@@ -24,10 +79,158 @@ const isShortStory = (storyLength: string | null | undefined): boolean => {
|
||||
return storyLengthLower.includes('short') || storyLengthLower.includes('1000');
|
||||
};
|
||||
|
||||
// Split story content into sections based on the number of scenes
|
||||
const splitStoryContent = (content: string, numSections: number): string[] => {
|
||||
if (!content || numSections <= 1) {
|
||||
return [content || ''];
|
||||
}
|
||||
|
||||
// Split by paragraphs (double newlines)
|
||||
const paragraphs = content.split(/\n\s*\n/).filter(p => p.trim().length > 0);
|
||||
|
||||
if (paragraphs.length === 0) {
|
||||
return [content];
|
||||
}
|
||||
|
||||
// If we have fewer paragraphs than sections, use paragraphs as sections
|
||||
if (paragraphs.length <= numSections) {
|
||||
// Pad with empty sections if needed
|
||||
const sections = [...paragraphs];
|
||||
while (sections.length < numSections) {
|
||||
sections.push('');
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Divide paragraphs into roughly equal sections
|
||||
const sections: string[] = [];
|
||||
const paragraphsPerSection = Math.ceil(paragraphs.length / numSections);
|
||||
|
||||
for (let i = 0; i < numSections; i++) {
|
||||
const start = i * paragraphsPerSection;
|
||||
const end = Math.min(start + paragraphsPerSection, paragraphs.length);
|
||||
sections.push(paragraphs.slice(start, end).join('\n\n'));
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
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 [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
const [pageDirection, setPageDirection] = useState(0);
|
||||
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
|
||||
// Get scenes and images from state
|
||||
const scenes = state.outlineScenes || [];
|
||||
const sceneImages = state.sceneImages || new Map<number, string>();
|
||||
const hasScenes = state.isOutlineStructured && scenes.length > 0;
|
||||
|
||||
// Split story content into sections mapped to scenes
|
||||
const storySections = useMemo(() => {
|
||||
if (!state.storyContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (hasScenes && scenes.length > 0) {
|
||||
// Split story content into sections based on number of scenes
|
||||
return splitStoryContent(state.storyContent, scenes.length);
|
||||
}
|
||||
|
||||
// If no scenes, treat entire story as one section
|
||||
return [state.storyContent];
|
||||
}, [state.storyContent, hasScenes, scenes.length]);
|
||||
|
||||
const numPages = Math.max(storySections.length, hasScenes ? scenes.length : 1);
|
||||
const currentPage = currentPageIndex < storySections.length ? storySections[currentPageIndex] : '';
|
||||
const currentSceneIndex = hasScenes ? Math.min(currentPageIndex, scenes.length - 1) : 0;
|
||||
const currentScene = hasScenes ? scenes[currentSceneIndex] : null;
|
||||
const canGoPrev = currentPageIndex > 0;
|
||||
const canGoNext = currentPageIndex < numPages - 1;
|
||||
|
||||
// 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
|
||||
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 page changes
|
||||
useEffect(() => {
|
||||
setImageLoadError((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(currentSceneNumber);
|
||||
return next;
|
||||
});
|
||||
}, [currentSceneNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
if (storySections.length > 0) {
|
||||
setCurrentPageIndex(0);
|
||||
setPageDirection(0);
|
||||
}
|
||||
}, [storySections.length]);
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (canGoPrev) {
|
||||
setPageDirection(-1);
|
||||
setCurrentPageIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (canGoNext) {
|
||||
setPageDirection(1);
|
||||
setCurrentPageIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateStart = async () => {
|
||||
if (!state.premise || (!state.outline && !state.outlineScenes)) {
|
||||
@@ -178,19 +381,26 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
sx={{
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
backgroundColor: '#F7F3E9',
|
||||
color: '#2C2416',
|
||||
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>
|
||||
<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)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{state.storyContent && (
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037', fontStyle: 'italic' }}>
|
||||
<Typography variant="body2" sx={{ mb: 3, 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)</>
|
||||
@@ -212,15 +422,248 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
|
||||
{state.storyContent ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={20}
|
||||
value={state.storyContent}
|
||||
onChange={(e) => state.setStoryContent(e.target.value)}
|
||||
label="Story Content"
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
{hasScenes && numPages > 1 ? (
|
||||
// Book-like UI with images
|
||||
<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%)',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait" custom={pageDirection}>
|
||||
<MotionBox
|
||||
key={`book-pages-${currentPageIndex}`}
|
||||
custom={pageDirection}
|
||||
variants={{}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Left page - Image */}
|
||||
<MotionBox
|
||||
key={`image-${currentPageIndex}`}
|
||||
role="button"
|
||||
aria-label="Previous page"
|
||||
onClick={handlePrevPage}
|
||||
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',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
'&: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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{currentSceneImageFullUrl ? (
|
||||
<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={() => {
|
||||
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: '300px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#7a5335',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ textAlign: 'center' }}>
|
||||
{currentScene?.image_prompt || 'No image available for this scene'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2, width: '100%' }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Click to turn back
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#a37b55' }}>
|
||||
{canGoPrev ? '← Previous page' : 'Start of story'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
|
||||
{/* Right page - Story text */}
|
||||
<MotionBox
|
||||
key={`story-${currentPageIndex}`}
|
||||
role="button"
|
||||
aria-label="Next page"
|
||||
onClick={handleNextPage}
|
||||
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',
|
||||
lineHeight: 1.8,
|
||||
fontFamily: `'Georgia', 'Times New Roman', serif`,
|
||||
fontSize: '1.1rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{currentPage || 'Loading...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: '#a37b55' }}>
|
||||
{canGoNext ? 'Next page →' : 'End of story'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Page {currentPageIndex + 1} of {numPages}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
// Simple text display if no scenes
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
backgroundColor: '#FAF9F6',
|
||||
minHeight: '400px',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#2C2416',
|
||||
lineHeight: 1.8,
|
||||
fontFamily: `'Georgia', 'Times New Roman', serif`,
|
||||
fontSize: '1.1rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{state.storyContent}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Multimedia Generation Section */}
|
||||
{state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0 && (
|
||||
<MultimediaSection state={state} />
|
||||
)}
|
||||
|
||||
<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) && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
198
frontend/src/components/StoryWriter/StoryWriterLanding.tsx
Normal file
198
frontend/src/components/StoryWriter/StoryWriterLanding.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Grid, Paper, Typography } from '@mui/material';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
|
||||
|
||||
interface StoryWriterLandingProps {
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
const featureHighlights = [
|
||||
{
|
||||
title: 'AI Story Blueprint',
|
||||
description: 'Persona, setting, tone, and premise woven together automatically.',
|
||||
detail: 'Start with cohesive outlines tailored to your audience and genre.',
|
||||
icon: <MenuBookIcon sx={{ fontSize: 32, color: '#8D5524' }} />,
|
||||
},
|
||||
{
|
||||
title: 'Cinematic Illustrations',
|
||||
description: 'Scene-by-scene image prompts and gallery-ready renders.',
|
||||
detail: 'Control aspect ratios, providers, and models for every chapter.',
|
||||
icon: <ImageIcon sx={{ fontSize: 32, color: '#B25D3E' }} />,
|
||||
},
|
||||
{
|
||||
title: 'Voice-Ready Narration',
|
||||
description: 'Generate lifelike audio in multiple languages and speeds.',
|
||||
detail: 'Perfect for bedtime stories, podcasts, or accessibility-ready scripts.',
|
||||
icon: <VolumeUpIcon sx={{ fontSize: 32, color: '#7A4C9F' }} />,
|
||||
},
|
||||
{
|
||||
title: 'Story Video Composer',
|
||||
description: 'Blend scenes, audio, and transitions into immersive videos.',
|
||||
detail: 'Fine-tune FPS, transitions, and pacing for a studio polish.',
|
||||
icon: <VideoLibraryIcon sx={{ fontSize: 32, color: '#2E7D83' }} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const StoryWriterLanding: React.FC<StoryWriterLandingProps> = ({ onStart }) => {
|
||||
return (
|
||||
<Box sx={{ py: 6 }}>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'.storywriter-landing-shadow': {
|
||||
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.25)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 5, display: 'flex', justifyContent: 'center' }}>
|
||||
<Box
|
||||
className="storywriter-landing-shadow"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: { xs: '100%', lg: '90vw' },
|
||||
maxWidth: 1400,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
borderRadius: '24px',
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(120deg, #fff9ef 0%, #f5e1c7 45%, #fff9ef 100%)',
|
||||
border: '1px solid rgba(120, 90, 60, 0.28)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 4, md: 6 },
|
||||
borderRight: { md: '1px solid rgba(120, 90, 60, 0.18)' },
|
||||
background: 'linear-gradient(100deg, rgba(255,255,255,0.85) 0%, rgba(242,226,204,0.95) 100%)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 6, color: '#7a5335', fontWeight: 600 }}>
|
||||
Story Text & Blueprint
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontFamily: `'Playfair Display', serif`, color: '#2C2416', mb: 2 }}>
|
||||
Watch Alwrity AI open your storybook
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#3f3224', lineHeight: 1.8, mb: 3 }}>
|
||||
Begin with a book-inspired canvas. Alwrity assembles personas, settings, tones, and story beats so you can
|
||||
focus on imagination, not forms.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{[
|
||||
'AI-curated personas, stakes, and endings',
|
||||
'Guided tone, POV, rating, and length controls',
|
||||
'Scene-by-scene descriptions ready for writing',
|
||||
].map((item) => (
|
||||
<Typography key={item} variant="body2" sx={{ color: '#5D4037' }}>
|
||||
• {item}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 4, md: 6 },
|
||||
background: 'linear-gradient(260deg, rgba(255,255,255,0.9) 0%, rgba(243,226,206,0.95) 100%)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 6, color: '#7a5335', fontWeight: 600 }}>
|
||||
Multimedia Magic
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontFamily: `'Playfair Display', serif`, color: '#2C2416', mb: 2 }}>
|
||||
Illustrations, narration, and video on tap
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#3f3224', lineHeight: 1.8, mb: 3 }}>
|
||||
Every scene can bloom into art, audio, and cinematic video. Toggle features that matter and let AI stitch
|
||||
them together.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{[
|
||||
'High-fidelity prompts for image generators',
|
||||
'Narration in multiple languages and speeds',
|
||||
'Video assembly with scene transitions and audio sync',
|
||||
].map((item) => (
|
||||
<Typography key={item} variant="body2" sx={{ color: '#5D4037' }}>
|
||||
• {item}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mb: 5 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={onStart}
|
||||
sx={{
|
||||
mb: 1,
|
||||
px: 5,
|
||||
py: 1.8,
|
||||
borderRadius: '999px',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
|
||||
boxShadow: '0 16px 32px rgba(127, 90, 240, 0.35)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #6c4cd4 0%, #24a26f 100%)',
|
||||
boxShadow: '0 18px 36px rgba(127, 90, 240, 0.4)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Let’s ALwrity Your Story Journey
|
||||
</Button>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }}>
|
||||
Tap once to open the book. Inputs appear after AI drafts your foundation.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, color: '#1A1611', mb: 2 }}>
|
||||
Everything Story Writer helps you create
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{featureHighlights.map((feature) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={feature.title}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: '100%',
|
||||
p: 3,
|
||||
background: 'linear-gradient(180deg, #fff8ef 0%, #f8efe2 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(138, 85, 36, 0.18)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{feature.icon}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#2C2416' }}>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#7A5A3C' }}>
|
||||
{feature.detail}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryWriterLanding;
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError, aiApiClient } from '../../../api/client';
|
||||
|
||||
interface MultimediaSectionProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
}
|
||||
|
||||
export const MultimediaSection: React.FC<MultimediaSectionProps> = ({ state }) => {
|
||||
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
|
||||
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||||
const [audioProgress, setAudioProgress] = useState(0);
|
||||
const [videoProgress, setVideoProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedScenes, setSelectedScenes] = useState<Set<number>>(new Set());
|
||||
const [showSceneSelection, setShowSceneSelection] = useState(false);
|
||||
const [audioBlobUrls, setAudioBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
|
||||
const hasScenes = state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0;
|
||||
const narrationEnabled = state.enableNarration;
|
||||
const videoEnabled = state.enableVideoNarration;
|
||||
const hasAudio = narrationEnabled && state.sceneAudio && state.sceneAudio.size > 0;
|
||||
const hasVideo = videoEnabled && !!state.storyVideo;
|
||||
const hasImages = state.sceneImages && state.sceneImages.size > 0;
|
||||
|
||||
// Initialize selected scenes to all scenes by default
|
||||
useEffect(() => {
|
||||
if (!narrationEnabled || !state.outlineScenes) {
|
||||
setSelectedScenes(new Set());
|
||||
return;
|
||||
}
|
||||
setSelectedScenes((prev) => {
|
||||
if (prev.size > 0) return prev;
|
||||
const scenes = state.outlineScenes ?? [];
|
||||
const allSceneNumbers = new Set(
|
||||
scenes.map((scene: any, index: number) => scene.scene_number || index + 1),
|
||||
);
|
||||
return allSceneNumbers;
|
||||
});
|
||||
}, [narrationEnabled, state.outlineScenes]);
|
||||
|
||||
const canGenerateAudio = hasScenes && selectedScenes.size > 0 && !isGeneratingAudio;
|
||||
const canGenerateVideo = hasScenes && hasImages && hasAudio && !isGeneratingVideo;
|
||||
|
||||
const handleSceneSelectionToggle = (sceneNumber: number) => {
|
||||
setSelectedScenes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sceneNumber)) {
|
||||
next.delete(sceneNumber);
|
||||
} else {
|
||||
next.add(sceneNumber);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllScenes = () => {
|
||||
if (hasScenes && state.outlineScenes) {
|
||||
const allSceneNumbers = new Set(
|
||||
state.outlineScenes.map((scene: any, index: number) =>
|
||||
scene.scene_number || index + 1
|
||||
)
|
||||
);
|
||||
setSelectedScenes(allSceneNumbers);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllScenes = () => {
|
||||
setSelectedScenes(new Set());
|
||||
};
|
||||
|
||||
// Fetch authenticated audio blobs for playback
|
||||
useEffect(() => {
|
||||
const sceneAudioMap = state.sceneAudio;
|
||||
if (!narrationEnabled || !sceneAudioMap || sceneAudioMap.size === 0) {
|
||||
setAudioBlobUrls((prev) => {
|
||||
prev.forEach((url) => URL.revokeObjectURL(url));
|
||||
return new Map();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadAudioBlobs = async () => {
|
||||
const entries = Array.from(sceneAudioMap.entries());
|
||||
const blobEntries: Array<[number, string]> = [];
|
||||
|
||||
for (const [sceneNumber, audioPath] of entries) {
|
||||
if (!audioPath) continue;
|
||||
try {
|
||||
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
|
||||
const response = await aiApiClient.get(normalizedPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
blobEntries.push([sceneNumber, blobUrl]);
|
||||
} catch (err) {
|
||||
console.error('Failed to load audio blob:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMounted) {
|
||||
blobEntries.forEach(([, url]) => URL.revokeObjectURL(url));
|
||||
return;
|
||||
}
|
||||
|
||||
setAudioBlobUrls((prev) => {
|
||||
prev.forEach((url) => URL.revokeObjectURL(url));
|
||||
return new Map(blobEntries);
|
||||
});
|
||||
};
|
||||
|
||||
loadAudioBlobs();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
setAudioBlobUrls((prev) => {
|
||||
prev.forEach((url) => URL.revokeObjectURL(url));
|
||||
return new Map();
|
||||
});
|
||||
};
|
||||
}, [state.sceneAudio, narrationEnabled]);
|
||||
|
||||
const handleGenerateAudio = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
if (!narrationEnabled) {
|
||||
setError('Narration feature is disabled in Story Setup.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedScenes.size === 0) {
|
||||
setError('Please select at least one scene to generate audio for');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAudio(true);
|
||||
setError(null);
|
||||
setAudioProgress(0);
|
||||
|
||||
try {
|
||||
// Filter scenes to only selected ones
|
||||
const scenesToGenerate = state.outlineScenes.filter((scene: any, index: number) => {
|
||||
const sceneNumber = scene.scene_number || index + 1;
|
||||
return selectedScenes.has(sceneNumber);
|
||||
});
|
||||
|
||||
const response = await storyWriterApi.generateSceneAudio({
|
||||
scenes: scenesToGenerate,
|
||||
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);
|
||||
setAudioProgress(100);
|
||||
} else {
|
||||
throw new Error('Failed to generate audio');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Audio generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402)
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
setIsGeneratingAudio(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate audio';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateVideo = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
if (!videoEnabled) {
|
||||
setError('Story video feature is disabled in Story Setup.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasImages) {
|
||||
setError('Please generate images for scenes first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAudio) {
|
||||
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');
|
||||
}
|
||||
|
||||
setVideoProgress(30);
|
||||
|
||||
// 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) {
|
||||
console.error('Video generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402)
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
setIsGeneratingVideo(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasScenes) {
|
||||
return null; // Don't show if no scenes available
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
backgroundColor: '#FAF9F6',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600, color: '#1A1611' }}>
|
||||
Multimedia Generation
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3, color: '#5D4037' }}>
|
||||
Generate audio narration and video for your story scenes.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Audio Section */}
|
||||
{narrationEnabled ? (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<VolumeUpIcon sx={{ color: hasAudio ? '#4caf50' : '#5D4037' }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Audio Narration
|
||||
</Typography>
|
||||
{hasAudio && (
|
||||
<Chip
|
||||
icon={<CheckCircleIcon />}
|
||||
label="Generated"
|
||||
size="small"
|
||||
color="success"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant={hasAudio ? 'outlined' : 'contained'}
|
||||
startIcon={isGeneratingAudio ? <CircularProgress size={16} /> : <VolumeUpIcon />}
|
||||
onClick={handleGenerateAudio}
|
||||
disabled={!canGenerateAudio || isGeneratingAudio}
|
||||
>
|
||||
{hasAudio
|
||||
? 'Regenerate Selected'
|
||||
: `Generate Audio (${selectedScenes.size} scene${selectedScenes.size !== 1 ? 's' : ''})`}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{hasScenes && state.outlineScenes && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037', fontWeight: 500 }}>
|
||||
Select scenes to generate audio for:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={handleSelectAllScenes}
|
||||
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={handleDeselectAllScenes}
|
||||
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
|
||||
>
|
||||
Deselect All
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowSceneSelection(!showSceneSelection)}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
{showSceneSelection ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Collapse in={showSceneSelection}>
|
||||
<FormGroup sx={{ pl: 1 }}>
|
||||
{state.outlineScenes.map((scene: any, index: number) => {
|
||||
const sceneNumber = scene.scene_number || index + 1;
|
||||
const hasAudioForScene = state.sceneAudio?.has(sceneNumber);
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={sceneNumber}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedScenes.has(sceneNumber)}
|
||||
onChange={() => handleSceneSelectionToggle(sceneNumber)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2">
|
||||
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
|
||||
</Typography>
|
||||
{hasAudioForScene && (
|
||||
<CheckCircleIcon sx={{ fontSize: 16, color: '#4caf50' }} />
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FormGroup>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isGeneratingAudio && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress variant="indeterminate" />
|
||||
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
|
||||
Generating audio for {selectedScenes.size} selected scene
|
||||
{selectedScenes.size !== 1 ? 's' : ''}...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasAudio && state.sceneAudio && state.outlineScenes && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037', fontSize: '0.875rem', mb: 2 }}>
|
||||
Audio narration generated for {state.sceneAudio.size} scene(s). Listen to audio for each scene:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{state.outlineScenes.map((scene: any, index: number) => {
|
||||
const sceneNumber = scene.scene_number || index + 1;
|
||||
const audioUrl = state.sceneAudio?.get(sceneNumber);
|
||||
if (!audioUrl) return null;
|
||||
const blobUrl = audioBlobUrls.get(sceneNumber);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={sceneNumber}
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(120, 90, 60, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#1A1611' }}>
|
||||
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
|
||||
</Typography>
|
||||
<audio
|
||||
controls
|
||||
src={blobUrl ? blobUrl : storyWriterApi.getAudioUrl(audioUrl)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
Narration is disabled in Story Setup. Enable it to generate or listen to audio narration.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Video Section */}
|
||||
{videoEnabled ? (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<VideoLibraryIcon sx={{ color: hasVideo ? '#4caf50' : '#5D4037' }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Story Video
|
||||
</Typography>
|
||||
{hasVideo && (
|
||||
<Chip
|
||||
icon={<CheckCircleIcon />}
|
||||
label="Generated"
|
||||
size="small"
|
||||
color="success"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
{!hasVideo && !hasImages && (
|
||||
<Chip label="Images required" size="small" color="warning" sx={{ ml: 1 }} />
|
||||
)}
|
||||
{!hasVideo && hasImages && !hasAudio && (
|
||||
<Chip label="Audio required" size="small" color="warning" sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{hasVideo && (
|
||||
<Button variant="outlined" startIcon={<DownloadIcon />} onClick={handleDownloadVideo}>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={hasVideo ? 'outlined' : 'contained'}
|
||||
startIcon={isGeneratingVideo ? <CircularProgress size={16} /> : <VideoLibraryIcon />}
|
||||
onClick={handleGenerateVideo}
|
||||
disabled={!canGenerateVideo || isGeneratingVideo}
|
||||
>
|
||||
{hasVideo ? 'Regenerate Video' : 'Generate Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isGeneratingVideo && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant={videoProgress > 0 ? 'determinate' : 'indeterminate'}
|
||||
value={videoProgress}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
|
||||
Generating video... This may take a few minutes.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasVideo && state.storyVideo && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037', mb: 1, fontSize: '0.875rem' }}>
|
||||
Video ready! Preview and download below.
|
||||
</Typography>
|
||||
<Box
|
||||
component="video"
|
||||
controls
|
||||
src={storyWriterApi.getVideoUrl(state.storyVideo)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Story video generation is disabled in Story Setup. Enable it to create narrative videos.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Badge,
|
||||
CircularProgress,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
|
||||
interface MultimediaToolbarProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onGenerateAudio?: () => void;
|
||||
onGenerateVideo?: () => void;
|
||||
isGeneratingAudio?: boolean;
|
||||
isGeneratingVideo?: boolean;
|
||||
onOpenPanel?: (section: 'audio' | 'video') => void;
|
||||
}
|
||||
|
||||
export const MultimediaToolbar: React.FC<MultimediaToolbarProps> = ({
|
||||
state,
|
||||
onGenerateAudio,
|
||||
onGenerateVideo,
|
||||
isGeneratingAudio = false,
|
||||
isGeneratingVideo = false,
|
||||
onOpenPanel,
|
||||
}) => {
|
||||
const hasScenes = state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0;
|
||||
const hasAudio = state.enableNarration && state.sceneAudio && state.sceneAudio.size > 0;
|
||||
const hasVideo = state.enableVideoNarration && !!state.storyVideo;
|
||||
const hasImages = state.sceneImages && state.sceneImages.size > 0;
|
||||
|
||||
// Determine if audio generation is available
|
||||
const audioFeatureEnabled = state.enableNarration;
|
||||
const videoFeatureEnabled = state.enableVideoNarration;
|
||||
const canGenerateAudio = hasScenes && audioFeatureEnabled && !isGeneratingAudio;
|
||||
const canGenerateVideo = hasScenes && videoFeatureEnabled && hasImages && hasAudio && !isGeneratingVideo;
|
||||
|
||||
// Determine status for each
|
||||
const audioStatus = hasAudio ? 'success' : isGeneratingAudio ? 'loading' : 'idle';
|
||||
const videoStatus = hasVideo ? 'success' : isGeneratingVideo ? 'loading' : canGenerateVideo ? 'ready' : 'disabled';
|
||||
|
||||
const [audioMenuAnchor, setAudioMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [videoMenuAnchor, setVideoMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleAudioMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAudioMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleAudioMenuClose = () => {
|
||||
setAudioMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleVideoMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setVideoMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleVideoMenuClose = () => {
|
||||
setVideoMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleOpenPanel = (section: 'audio' | 'video') => {
|
||||
handleAudioMenuClose();
|
||||
handleVideoMenuClose();
|
||||
onOpenPanel?.(section);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Audio Generation Button */}
|
||||
<Tooltip
|
||||
title={
|
||||
!audioFeatureEnabled
|
||||
? 'Enable Narration in Story Setup'
|
||||
: !hasScenes
|
||||
? 'Generate outline first'
|
||||
: hasAudio
|
||||
? 'Audio generated ✓'
|
||||
: isGeneratingAudio
|
||||
? 'Generating audio...'
|
||||
: 'Generate audio narration'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={handleAudioMenuOpen}
|
||||
disabled={!hasScenes || !audioFeatureEnabled}
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{isGeneratingAudio ? (
|
||||
<CircularProgress size={20} sx={{ color: 'rgba(255, 255, 255, 0.9)' }} />
|
||||
) : hasAudio ? (
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
badgeContent={
|
||||
<CheckCircleIcon sx={{ fontSize: 12, color: '#4caf50' }} />
|
||||
}
|
||||
>
|
||||
<VolumeUpIcon fontSize="small" />
|
||||
</Badge>
|
||||
) : (
|
||||
<VolumeUpIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={audioMenuAnchor}
|
||||
open={Boolean(audioMenuAnchor)}
|
||||
onClose={handleAudioMenuClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuItem onClick={() => handleOpenPanel('audio')} disabled={!hasScenes || !audioFeatureEnabled}>
|
||||
<ListItemIcon>
|
||||
<PlayArrowIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Listen & manage audio" />
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleAudioMenuClose();
|
||||
onGenerateAudio?.();
|
||||
}}
|
||||
disabled={!canGenerateAudio || !onGenerateAudio}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={hasAudio ? 'Regenerate audio' : 'Generate audio for all scenes'}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Video Generation Button */}
|
||||
<Tooltip
|
||||
title={
|
||||
!videoFeatureEnabled
|
||||
? 'Enable Story Video in Story Setup'
|
||||
: !hasScenes
|
||||
? 'Generate outline first'
|
||||
: !hasImages
|
||||
? 'Generate images first'
|
||||
: !hasAudio
|
||||
? 'Generate audio first'
|
||||
: hasVideo
|
||||
? 'Video generated ✓'
|
||||
: isGeneratingVideo
|
||||
? 'Generating video...'
|
||||
: 'Generate video'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={handleVideoMenuOpen}
|
||||
disabled={!hasScenes || !videoFeatureEnabled}
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{isGeneratingVideo ? (
|
||||
<CircularProgress size={20} sx={{ color: 'rgba(255, 255, 255, 0.9)' }} />
|
||||
) : hasVideo ? (
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
badgeContent={
|
||||
<CheckCircleIcon sx={{ fontSize: 12, color: '#4caf50' }} />
|
||||
}
|
||||
>
|
||||
<VideoLibraryIcon fontSize="small" />
|
||||
</Badge>
|
||||
) : (
|
||||
<VideoLibraryIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={videoMenuAnchor}
|
||||
open={Boolean(videoMenuAnchor)}
|
||||
onClose={handleVideoMenuClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuItem onClick={() => handleOpenPanel('video')} disabled={!hasScenes || !videoFeatureEnabled}>
|
||||
<ListItemIcon>
|
||||
<PlayArrowIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="View video options" />
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleVideoMenuClose();
|
||||
onGenerateVideo?.();
|
||||
}}
|
||||
disabled={!canGenerateVideo || !onGenerateVideo}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={hasVideo ? 'Regenerate video' : 'Generate video'}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
624
frontend/src/components/shared/AlertsBadge.tsx
Normal file
624
frontend/src/components/shared/AlertsBadge.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Badge, IconButton, Menu, MenuItem, Typography, Box, Divider, Chip, Tooltip, List, ListItem, ListItemText, ListItemIcon, Button } from '@mui/material';
|
||||
import { Notifications as NotificationsIcon, NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material';
|
||||
import { Warning as WarningIcon, Error as ErrorIcon, Info as InfoIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material';
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../../api/schedulerDashboard';
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
source: 'billing' | 'scheduler' | 'task';
|
||||
metadata?: Record<string, any>;
|
||||
groupKey?: string;
|
||||
}
|
||||
|
||||
interface AlertGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
source: Alert['source'];
|
||||
severity: Alert['severity'];
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
summary: string;
|
||||
count: number;
|
||||
latestTimestamp: string;
|
||||
alerts: Alert[];
|
||||
metadata?: Record<string, any>;
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
}
|
||||
|
||||
interface AlertsBadgeProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
const { userId } = useAuth();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [alertGroups, setAlertGroups] = useState<AlertGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const open = Boolean(anchorEl);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isPollingRef = useRef(false);
|
||||
const schedulerDismissedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const getSchedulerStorageKey = (uid: string) => `scheduler_alerts_dismissed_${uid}`;
|
||||
|
||||
const loadSchedulerDismissed = (uid: string) => {
|
||||
if (!uid) return new Set<string>();
|
||||
try {
|
||||
const stored = localStorage.getItem(getSchedulerStorageKey(uid));
|
||||
if (!stored) return new Set<string>();
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(parsed);
|
||||
}
|
||||
return new Set<string>();
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
};
|
||||
|
||||
const persistSchedulerDismissed = (uid: string, dismissed: Set<string>) => {
|
||||
if (!uid) return;
|
||||
try {
|
||||
localStorage.setItem(getSchedulerStorageKey(uid), JSON.stringify(Array.from(dismissed)));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
const dismissSchedulerAlert = (alertId: string) => {
|
||||
if (!userId) return;
|
||||
const updated = new Set(schedulerDismissedRef.current);
|
||||
updated.add(alertId);
|
||||
schedulerDismissedRef.current = updated;
|
||||
persistSchedulerDismissed(userId, updated);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
schedulerDismissedRef.current = loadSchedulerDismissed(userId);
|
||||
}, [userId]);
|
||||
|
||||
// Fetch all alerts
|
||||
const rebuildGroups = (alertList: Alert[]) => {
|
||||
const groups = buildAlertGroups(alertList);
|
||||
setAlertGroups(groups);
|
||||
const unreadGroups = groups.filter(group => group.alerts.some(alert => !alert.is_read)).length;
|
||||
setUnreadCount(unreadGroups);
|
||||
};
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
if (!userId || isPollingRef.current) return;
|
||||
|
||||
try {
|
||||
isPollingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
const allAlerts: Alert[] = [];
|
||||
|
||||
// Phase 1: Fetch billing alerts
|
||||
try {
|
||||
const billingAlerts = await billingService.getUsageAlerts(userId, true);
|
||||
const formattedBillingAlerts: Alert[] = billingAlerts.map((alert: any) => ({
|
||||
id: `billing-${alert.id}`,
|
||||
type: alert.type,
|
||||
title: alert.title || 'Billing Alert',
|
||||
message: alert.message,
|
||||
severity: alert.severity || 'warning',
|
||||
priority: mapSeverityToPriority(alert.severity || 'warning'),
|
||||
is_read: alert.is_read || false,
|
||||
created_at: alert.created_at,
|
||||
source: 'billing' as const,
|
||||
groupKey: `billing-${alert.type}-${alert.title || 'alert'}`
|
||||
}));
|
||||
allAlerts.push(...formattedBillingAlerts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching billing alerts:', error);
|
||||
}
|
||||
|
||||
// Phase 2: Fetch scheduler/task alerts
|
||||
try {
|
||||
const taskAlerts = await getTasksNeedingIntervention(userId);
|
||||
const formattedSchedulerAlerts: Alert[] = taskAlerts.map((task: TaskNeedingIntervention) => {
|
||||
const alertId = `scheduler-${task.task_type}-${task.task_id}`;
|
||||
const failureReason = task.failure_pattern?.failure_reason || 'unknown';
|
||||
const reasonInfo = failureReasonDetails[failureReason] || failureReasonDetails.unknown;
|
||||
const taskLabel = formatTaskDisplayName(task);
|
||||
const message = buildSchedulerAlertMessage(task);
|
||||
const timestamp = task.failure_pattern?.last_failure_time || task.last_failure || new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: alertId,
|
||||
type: 'scheduler_task_failure',
|
||||
title: `Task needs attention: ${taskLabel}`,
|
||||
message,
|
||||
severity: reasonInfo.severity,
|
||||
priority: mapSchedulerReasonToPriority(failureReason),
|
||||
is_read: schedulerDismissedRef.current.has(alertId),
|
||||
created_at: timestamp,
|
||||
source: 'scheduler' as const,
|
||||
metadata: {
|
||||
taskId: task.task_id,
|
||||
taskType: task.task_type,
|
||||
failureReason,
|
||||
occurrences: task.failure_pattern?.consecutive_failures ?? 0,
|
||||
lastFailure: timestamp,
|
||||
},
|
||||
groupKey: `scheduler-${task.task_type}-${task.task_id}`
|
||||
};
|
||||
});
|
||||
allAlerts.push(...formattedSchedulerAlerts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching scheduler alerts:', error);
|
||||
}
|
||||
|
||||
// Sort alerts by created_at (newest first)
|
||||
allAlerts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
setAlerts(allAlerts);
|
||||
rebuildGroups(allAlerts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching alerts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isPollingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for alerts
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
fetchAlerts();
|
||||
// Poll every 60 seconds
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchAlerts();
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
// Refresh alerts when menu opens
|
||||
fetchAlerts();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (alert: Alert) => {
|
||||
try {
|
||||
if (alert.source === 'billing') {
|
||||
const numericId = Number(alert.id.replace('billing-', ''));
|
||||
if (!Number.isNaN(numericId)) {
|
||||
await billingService.markAlertRead(numericId);
|
||||
}
|
||||
} else if (alert.source === 'scheduler') {
|
||||
dismissSchedulerAlert(alert.id);
|
||||
}
|
||||
// Update local state
|
||||
const updated = alerts.map(a => (a.id === alert.id ? { ...a, is_read: true } : a));
|
||||
setAlerts(updated);
|
||||
rebuildGroups(updated);
|
||||
} catch (error) {
|
||||
console.error('Error marking alert as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupClick = async (group: AlertGroup) => {
|
||||
for (const alert of group.alerts.filter(a => !a.is_read)) {
|
||||
await handleMarkAsRead(alert);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
for (const alert of alerts.filter(a => !a.is_read)) {
|
||||
await handleMarkAsRead(alert);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking all alerts as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return <ErrorIcon sx={{ color: '#f44336', fontSize: 20 }} />;
|
||||
case 'warning':
|
||||
return <WarningIcon sx={{ color: '#ff9800', fontSize: 20 }} />;
|
||||
case 'info':
|
||||
return <InfoIcon sx={{ color: '#2196f3', fontSize: 20 }} />;
|
||||
default:
|
||||
return <InfoIcon sx={{ color: '#757575', fontSize: 20 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return '#f44336';
|
||||
case 'warning':
|
||||
return '#ff9800';
|
||||
case 'info':
|
||||
return '#2196f3';
|
||||
default:
|
||||
return '#757575';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (!userId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={unreadCount > 0 ? `${unreadCount} unread alert${unreadCount > 1 ? 's' : ''}` : 'No alerts'}>
|
||||
<IconButton
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
color: colorMode === 'dark' ? 'white' : 'inherit',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Badge badgeContent={unreadCount} color="error" max={99}>
|
||||
{unreadCount > 0 ? (
|
||||
<NotificationsActiveIcon sx={{ color: '#ff9800' }} />
|
||||
) : (
|
||||
<NotificationsIcon />
|
||||
)}
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
minWidth: 360,
|
||||
maxWidth: 450,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
Alerts
|
||||
</Typography>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleMarkAllAsRead}
|
||||
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
|
||||
>
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{loading && alertGroups.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading alerts...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : alertGroups.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 4, textAlign: 'center' }}>
|
||||
<CheckCircleIcon sx={{ fontSize: 48, color: '#4caf50', mb: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No alerts
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List sx={{ p: 0 }}>
|
||||
{alertGroups.map((group, index) => (
|
||||
<React.Fragment key={group.id}>
|
||||
<ListItem
|
||||
sx={{
|
||||
bgcolor: group.alerts.every(a => a.is_read) ? 'transparent' : 'rgba(255, 152, 0, 0.05)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleGroupClick(group)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getSeverityIcon(group.severity)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: group.alerts.every(a => a.is_read) ? 400 : 700 }}>
|
||||
{group.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={group.source}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: `${getSeverityColor(group.severity)}20`,
|
||||
color: getSeverityColor(group.severity),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={`${group.count} occurrence${group.count > 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: 'rgba(0,0,0,0.08)',
|
||||
color: colorMode === 'dark' ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={`${group.priority.toUpperCase()} priority`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: priorityStyles[group.priority].bg,
|
||||
color: priorityStyles[group.priority].color,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{group.summary}
|
||||
</Typography>
|
||||
{group.alerts.slice(0, 2).map((alert, idx) => (
|
||||
<Typography key={alert.id} variant="caption" color="text.secondary" sx={{ display: 'block', mt: idx === 0 ? 0.5 : 0 }}>
|
||||
• {alert.message}
|
||||
</Typography>
|
||||
))}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Last alert: {formatDate(group.latestTimestamp)}
|
||||
</Typography>
|
||||
{group.actionHref && (
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ mt: 1, textTransform: 'none', fontSize: '0.75rem' }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (group.actionHref?.startsWith('http')) {
|
||||
window.open(group.actionHref, '_blank');
|
||||
} else {
|
||||
window.location.href = group.actionHref!;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.actionLabel || 'View Details'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < alertGroups.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{alertGroups.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ px: 2, py: 1, textAlign: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
window.location.href = '/billing';
|
||||
}}
|
||||
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
|
||||
>
|
||||
View All Alerts
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const failureReasonDetails: Record<string, { label: string; severity: 'error' | 'warning' | 'info'; guidance: string }> = {
|
||||
api_limit: {
|
||||
label: 'API limit exceeded',
|
||||
severity: 'error',
|
||||
guidance: 'Usage quota exceeded. Consider upgrading or waiting for quota reset.',
|
||||
},
|
||||
auth_error: {
|
||||
label: 'Authentication error',
|
||||
severity: 'error',
|
||||
guidance: 'Refresh your platform credentials and retry the task.',
|
||||
},
|
||||
network_error: {
|
||||
label: 'Network error',
|
||||
severity: 'warning',
|
||||
guidance: 'Network instability detected. Retry once connectivity is restored.',
|
||||
},
|
||||
config_error: {
|
||||
label: 'Configuration issue',
|
||||
severity: 'warning',
|
||||
guidance: 'Review task configuration and ensure required inputs are set.',
|
||||
},
|
||||
unknown: {
|
||||
label: 'Unknown failure',
|
||||
severity: 'info',
|
||||
guidance: 'Check task logs for more details.',
|
||||
},
|
||||
};
|
||||
|
||||
const formatTaskDisplayName = (task: TaskNeedingIntervention): string => {
|
||||
if (task.task_type === 'oauth_token_monitoring') {
|
||||
return `OAuth ${task.platform?.toUpperCase() || 'Token'}`;
|
||||
}
|
||||
if (task.task_type === 'website_analysis') {
|
||||
if (task.website_url) {
|
||||
return `Website Analysis (${task.website_url})`;
|
||||
}
|
||||
return 'Website Analysis';
|
||||
}
|
||||
if (task.task_type.includes('_insights')) {
|
||||
return `${task.platform?.toUpperCase() || 'Platform'} Insights`;
|
||||
}
|
||||
return task.task_type.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const buildSchedulerAlertMessage = (task: TaskNeedingIntervention): string => {
|
||||
const reasonKey = task.failure_pattern?.failure_reason || 'unknown';
|
||||
const reasonInfo = failureReasonDetails[reasonKey] || failureReasonDetails.unknown;
|
||||
const consecutive = task.failure_pattern?.consecutive_failures ?? 0;
|
||||
const recent = task.failure_pattern?.recent_failures ?? 0;
|
||||
return `${reasonInfo.label}. ${consecutive} consecutive failures, ${recent} in the last 7 days. ${reasonInfo.guidance}`;
|
||||
};
|
||||
|
||||
const getAlertAction = (alert: Alert): { label?: string; href?: string } => {
|
||||
if (alert.source === 'billing') {
|
||||
return {
|
||||
label: 'Open Billing',
|
||||
href: '/billing',
|
||||
};
|
||||
}
|
||||
if (alert.source === 'scheduler') {
|
||||
const taskId = alert.metadata?.taskId;
|
||||
if (taskId) {
|
||||
return {
|
||||
label: `Review Task #${taskId}`,
|
||||
href: `/scheduler?taskId=${taskId}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'View Scheduler',
|
||||
href: '/scheduler#tasks',
|
||||
};
|
||||
}
|
||||
if (alert.source === 'task') {
|
||||
return {
|
||||
label: 'View Tasks',
|
||||
href: '/tasks',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const mapSeverityToPriority = (severity: string): 'high' | 'medium' | 'low' => {
|
||||
if (severity === 'error') return 'high';
|
||||
if (severity === 'warning') return 'medium';
|
||||
return 'low';
|
||||
};
|
||||
|
||||
const mapSchedulerReasonToPriority = (reason: string): 'high' | 'medium' | 'low' => {
|
||||
switch (reason) {
|
||||
case 'api_limit':
|
||||
case 'auth_error':
|
||||
return 'high';
|
||||
case 'network_error':
|
||||
return 'medium';
|
||||
default:
|
||||
return 'low';
|
||||
}
|
||||
};
|
||||
|
||||
const priorityRank: Record<'high' | 'medium' | 'low', number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
const priorityStyles: Record<'high' | 'medium' | 'low', { bg: string; color: string }> = {
|
||||
high: { bg: 'rgba(244,67,54,0.15)', color: '#f44336' },
|
||||
medium: { bg: 'rgba(255,152,0,0.2)', color: '#ff9800' },
|
||||
low: { bg: 'rgba(33,150,243,0.15)', color: '#2196f3' },
|
||||
};
|
||||
|
||||
const buildAlertGroups = (alertList: Alert[]): AlertGroup[] => {
|
||||
const map = new Map<string, AlertGroup>();
|
||||
|
||||
for (const alert of alertList) {
|
||||
const key = alert.groupKey || `${alert.source}-${alert.type}-${alert.title}`;
|
||||
const existing = map.get(key);
|
||||
const timestamp = alert.created_at;
|
||||
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.alerts.push(alert);
|
||||
if (new Date(timestamp).getTime() > new Date(existing.latestTimestamp).getTime()) {
|
||||
existing.latestTimestamp = timestamp;
|
||||
existing.summary = alert.message;
|
||||
}
|
||||
if (priorityRank[alert.priority] < priorityRank[existing.priority]) {
|
||||
existing.priority = alert.priority;
|
||||
existing.severity = alert.severity;
|
||||
}
|
||||
} else {
|
||||
const action = getAlertAction(alert);
|
||||
map.set(key, {
|
||||
id: key,
|
||||
title: alert.title,
|
||||
source: alert.source,
|
||||
severity: alert.severity,
|
||||
priority: alert.priority,
|
||||
summary: alert.message,
|
||||
count: 1,
|
||||
latestTimestamp: timestamp,
|
||||
alerts: [alert],
|
||||
metadata: alert.metadata,
|
||||
actionLabel: action.label,
|
||||
actionHref: action.href,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
const priorityCompare = priorityRank[a.priority] - priorityRank[b.priority];
|
||||
if (priorityCompare !== 0) return priorityCompare;
|
||||
return new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
export default AlertsBadge;
|
||||
@@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Chip, Button, Tooltip } from '@mui/material';
|
||||
import { PlayArrow } from '@mui/icons-material';
|
||||
import { ShimmerHeader } from './styled';
|
||||
import UserBadge from './UserBadge';
|
||||
import UsageDashboard from './UsageDashboard';
|
||||
import HeaderControls from './HeaderControls';
|
||||
import { DashboardHeaderProps } from './types';
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
@@ -407,10 +406,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
)}
|
||||
{rightContent}
|
||||
|
||||
{/* Usage Dashboard - Show API usage statistics */}
|
||||
<UsageDashboard compact={true} />
|
||||
|
||||
<UserBadge colorMode="dark" />
|
||||
<HeaderControls colorMode="dark" />
|
||||
</Box>
|
||||
</Box>
|
||||
</ShimmerHeader>
|
||||
|
||||
32
frontend/src/components/shared/HeaderControls.tsx
Normal file
32
frontend/src/components/shared/HeaderControls.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import AlertsBadge from './AlertsBadge';
|
||||
import UserBadge from './UserBadge';
|
||||
|
||||
interface HeaderControlsProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
showAlerts?: boolean;
|
||||
showUser?: boolean;
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
const HeaderControls: React.FC<HeaderControlsProps> = ({
|
||||
colorMode = 'light',
|
||||
showAlerts = true,
|
||||
showUser = true,
|
||||
gap = 1.5,
|
||||
}) => {
|
||||
if (!showAlerts && !showUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap }}>
|
||||
{showAlerts && <AlertsBadge colorMode={colorMode} />}
|
||||
{showUser && <UserBadge colorMode={colorMode} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderControls;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip } from '@mui/material';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider } from '@mui/material';
|
||||
import { useUser, useClerk } from '@clerk/clerk-react';
|
||||
import { useSubscription } from '../../contexts/SubscriptionContext';
|
||||
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
|
||||
import UsageDashboard from './UsageDashboard';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface UserBadgeProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
@@ -12,6 +15,7 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
const { signOut } = useClerk();
|
||||
const { subscription } = useSubscription();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<'healthy' | 'warning' | 'critical' | 'unknown'>('unknown');
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const initials = React.useMemo(() => {
|
||||
@@ -20,8 +24,43 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
return (first + last || user?.username?.[0] || user?.primaryEmailAddress?.emailAddress?.[0] || '?').toUpperCase();
|
||||
}, [user]);
|
||||
|
||||
// Fetch system status for status bulb
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/content-planning/monitoring/lightweight-stats');
|
||||
const result = response.data;
|
||||
if (result.status === 'success' && result.data) {
|
||||
setSystemStatus(result.data.status || 'unknown');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching system status:', err);
|
||||
setSystemStatus('unknown');
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemStatus();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchSystemStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!isSignedIn) return null;
|
||||
|
||||
// Get status bulb color
|
||||
const getStatusBulbColor = () => {
|
||||
switch (systemStatus) {
|
||||
case 'healthy':
|
||||
return '#4caf50'; // Green
|
||||
case 'warning':
|
||||
return '#ff9800'; // Orange
|
||||
case 'critical':
|
||||
return '#f44336'; // Red
|
||||
default:
|
||||
return '#757575'; // Gray for unknown
|
||||
}
|
||||
};
|
||||
|
||||
// Get plan display info
|
||||
const getPlanColor = () => {
|
||||
switch (subscription?.plan) {
|
||||
@@ -65,24 +104,65 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'}`}>
|
||||
<Avatar
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
|
||||
color: colorMode === 'dark' ? 'white' : 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
src={user?.imageUrl || undefined}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'} - System: ${systemStatus.toUpperCase()}`}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<Avatar
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
|
||||
color: colorMode === 'dark' ? 'white' : 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
src={user?.imageUrl || undefined}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
{/* Status Bulb */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: getStatusBulbColor(),
|
||||
border: `2px solid ${colorMode === 'dark' ? '#1a1a1a' : 'white'}`,
|
||||
boxShadow: `0 0 8px ${getStatusBulbColor()}80`,
|
||||
animation: systemStatus === 'healthy' ? 'pulse 2s ease-in-out infinite' : 'none',
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: 1,
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
'50%': {
|
||||
opacity: 0.8,
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
minWidth: 320,
|
||||
maxWidth: 400,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{user?.fullName || user?.username || 'User'}
|
||||
@@ -110,6 +190,50 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* System Status Indicator */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'rgba(0,0,0,0.02)',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
|
||||
System Health
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', '& > *': { transform: 'scale(0.85)' } }}>
|
||||
<SystemStatusIndicator />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Usage Dashboard */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'rgba(0,0,0,0.02)',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
|
||||
Usage Statistics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<UsageDashboard compact={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }}>
|
||||
Manage Subscription
|
||||
</MenuItem>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Audiotrack as AudioIcon,
|
||||
VideoLibrary as VideoIcon
|
||||
} from '@mui/icons-material';
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
import { ToolCategories } from '../components/shared/types';
|
||||
|
||||
export const toolCategories: ToolCategories = {
|
||||
@@ -37,6 +38,15 @@ export const toolCategories: ToolCategories = {
|
||||
features: ['SEO Optimized', 'Multiple Formats', 'Custom Tone', 'Research Integration', 'Plagiarism Free'],
|
||||
isHighlighted: true
|
||||
},
|
||||
{
|
||||
name: 'Story Writer',
|
||||
description: 'Create stories with AI: outline, images, narration, and video',
|
||||
icon: React.createElement(MenuBookIcon),
|
||||
status: 'beta',
|
||||
path: '/story-writer',
|
||||
features: ['Structured Outline', 'Image Generation', 'Audio Narration', 'Story Video'],
|
||||
isHighlighted: true
|
||||
},
|
||||
{
|
||||
name: 'Image Generator',
|
||||
description: 'AI image creation and visual content generation',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { showToastNotification } from '../utils/toastNotifications';
|
||||
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../api/schedulerDashboard';
|
||||
|
||||
/**
|
||||
* Hook to poll for tasks needing intervention and show toast notifications
|
||||
@@ -31,32 +31,7 @@ export function useSchedulerTaskAlerts(options: {
|
||||
isPollingRef.current = true;
|
||||
|
||||
// Fetch tasks needing intervention
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
tasks: Array<{
|
||||
task_id: number;
|
||||
task_type: string;
|
||||
user_id: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
failure_pattern: {
|
||||
consecutive_failures: number;
|
||||
recent_failures: number;
|
||||
failure_reason: string;
|
||||
last_failure_time: string | null;
|
||||
error_patterns: string[];
|
||||
};
|
||||
failure_reason: string | null;
|
||||
last_failure: string | null;
|
||||
}>;
|
||||
count: number;
|
||||
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = response.data.tasks || [];
|
||||
const tasks: TaskNeedingIntervention[] = await getTasksNeedingIntervention(userId);
|
||||
|
||||
// Show toast only for critical failures (API limits) - other failures are shown in dedicated section
|
||||
for (const task of tasks) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface StoryWriterState {
|
||||
storyLength: string;
|
||||
enableExplainer: boolean;
|
||||
enableIllustration: boolean;
|
||||
enableNarration: boolean;
|
||||
enableVideoNarration: boolean;
|
||||
|
||||
// Image generation settings
|
||||
@@ -75,6 +76,7 @@ const DEFAULT_STATE: Partial<StoryWriterState> = {
|
||||
storyLength: 'Medium',
|
||||
enableExplainer: true,
|
||||
enableIllustration: true,
|
||||
enableNarration: true,
|
||||
enableVideoNarration: true,
|
||||
// Image generation settings
|
||||
imageProvider: null,
|
||||
@@ -252,6 +254,10 @@ export const useStoryWriterState = () => {
|
||||
setState((prev) => ({ ...prev, enableIllustration: enabled }));
|
||||
}, []);
|
||||
|
||||
const setEnableNarration = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({ ...prev, enableNarration: enabled }));
|
||||
}, []);
|
||||
|
||||
const setEnableVideoNarration = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({ ...prev, enableVideoNarration: enabled }));
|
||||
}, []);
|
||||
@@ -371,6 +377,7 @@ export const useStoryWriterState = () => {
|
||||
story_length: state.storyLength,
|
||||
enable_explainer: state.enableExplainer,
|
||||
enable_illustration: state.enableIllustration,
|
||||
enable_narration: state.enableNarration,
|
||||
enable_video_narration: state.enableVideoNarration,
|
||||
// Image generation settings
|
||||
image_provider: state.imageProvider || undefined,
|
||||
@@ -422,6 +429,7 @@ export const useStoryWriterState = () => {
|
||||
setStoryLength,
|
||||
setEnableExplainer,
|
||||
setEnableIllustration,
|
||||
setEnableNarration,
|
||||
setEnableVideoNarration,
|
||||
setImageProvider,
|
||||
setImageWidth,
|
||||
|
||||
@@ -38,6 +38,7 @@ import TaskMonitoringTabs from '../components/SchedulerDashboard/TaskMonitoringT
|
||||
import { TerminalTypography, terminalColors } from '../components/SchedulerDashboard/terminalTheme';
|
||||
import { useSchedulerTaskAlerts } from '../hooks/useSchedulerTaskAlerts';
|
||||
import TasksNeedingIntervention from '../components/SchedulerDashboard/TasksNeedingIntervention';
|
||||
import HeaderControls from '../components/shared/HeaderControls';
|
||||
|
||||
// Terminal-themed styled components
|
||||
const TerminalContainer = styled(Container)(({ theme }) => ({
|
||||
@@ -451,6 +452,7 @@ const SchedulerDashboard: React.FC = () => {
|
||||
</TerminalIconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<HeaderControls colorMode="dark" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface StoryGenerationRequest {
|
||||
story_length?: string;
|
||||
enable_explainer?: boolean;
|
||||
enable_illustration?: boolean;
|
||||
enable_narration?: boolean;
|
||||
enable_video_narration?: boolean;
|
||||
// Image generation settings
|
||||
image_provider?: string;
|
||||
|
||||
Reference in New Issue
Block a user