Podcast Maker: Fix progress modals, research JSON, header stepper, voice/podcastMode chips

This commit is contained in:
ajaysi
2026-04-19 13:16:59 +05:30
parent ff61708e29
commit e704aa7d87
61 changed files with 7965 additions and 368 deletions

View File

@@ -5,6 +5,7 @@ import { CopilotKit } from "@copilotkit/react-core";
import { CopilotKitHealthProvider } from '../../contexts/CopilotKitHealthContext';
import CopilotKitDegradedBanner from '../shared/CopilotKitDegradedBanner';
import ErrorBoundary from '../shared/ErrorBoundary';
import { isPodcastOnlyDemoMode } from '../../utils/demoMode';
interface ConditionalCopilotKitProps {
children: React.ReactNode;
@@ -23,7 +24,8 @@ export const AuthenticatedCopilotWrapper: React.FC<AuthenticatedCopilotWrapperPr
const { isSignedIn } = useAuth();
const location = useLocation();
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding');
const isPodcastOnly = isPodcastOnlyDemoMode();
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding') || isPodcastOnly;
if (shouldExcludeCopilot) {
return <>{children}</>;

View File

@@ -13,6 +13,8 @@ interface AnalysisPanelProps {
idea?: string;
duration?: number;
speakers?: number;
voiceName?: string;
podcastMode?: "audio_only" | "video_only" | "audio_video";
avatarUrl?: string | null;
avatarPrompt?: string | null;
bible?: PodcastBible | null;
@@ -62,6 +64,8 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
idea,
duration,
speakers,
voiceName,
podcastMode,
avatarUrl,
avatarPrompt,
bible,
@@ -423,6 +427,8 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
idea={idea}
duration={duration}
speakers={speakers}
voiceName={voiceName}
podcastMode={podcastMode}
avatarUrl={avatarUrl}
avatarPrompt={avatarPrompt}
avatarBlobUrl={avatarBlobUrl}

View File

@@ -0,0 +1,157 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { PodcastAnalysis, PodcastEstimate, PodcastBible } from "../types";
export type TabId = "inputs" | "audience" | "outline" | "details" | "takeaways" | "guest";
interface AnalysisPanelContextType {
activeTab: TabId;
setActiveTab: (tab: TabId) => void;
analysis: PodcastAnalysis | null;
estimate: PodcastEstimate | null;
idea?: string;
duration?: number;
speakers?: number;
avatarUrl?: string | null;
avatarPrompt?: string | null;
bible?: PodcastBible | null;
isEditing: boolean;
setIsEditing: (editing: boolean) => void;
editedAnalysis: PodcastAnalysis | null;
setEditedAnalysis: React.Dispatch<React.SetStateAction<PodcastAnalysis | null>>;
currentAnalysis: PodcastAnalysis | null;
handleRemoveKeyword: (keyword: string) => void;
handleAddKeyword: (keyword: string) => void;
handleRemoveTitle: (title: string) => void;
handleAddTitle: (title: string) => void;
handleUpdateOutline: (id: string | number, field: 'title' | 'segments', value: any) => void;
onRegenerate?: () => void;
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
onUpdateBible?: (updatedBible: PodcastBible) => void;
}
const AnalysisPanelContext = createContext<AnalysisPanelContextType | undefined>(undefined);
interface AnalysisPanelProviderProps {
children: ReactNode;
analysis: PodcastAnalysis | null;
estimate: PodcastEstimate | null;
idea?: string;
duration?: number;
speakers?: number;
avatarUrl?: string | null;
avatarPrompt?: string | null;
bible?: PodcastBible | null;
onRegenerate?: () => void;
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
onUpdateBible?: (updatedBible: PodcastBible) => void;
}
export const AnalysisPanelProvider: React.FC<AnalysisPanelProviderProps> = ({
children,
analysis,
estimate,
idea,
duration,
speakers,
avatarUrl,
avatarPrompt,
bible,
onRegenerate,
onUpdateAnalysis,
onUpdateBible,
}) => {
const [activeTab, setActiveTab] = useState<TabId>("inputs");
const [isEditing, setIsEditing] = useState(false);
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
useEffect(() => {
if (analysis && !editedAnalysis) {
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
}
}, [analysis, editedAnalysis]);
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
const handleAddKeyword = (keyword: string) => {
if (!editedAnalysis || !keyword.trim()) return;
if (editedAnalysis.topKeywords.includes(keyword.trim())) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: [...editedAnalysis.topKeywords, keyword.trim()]
});
};
const handleRemoveKeyword = (keyword: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: editedAnalysis.topKeywords.filter(k => k !== keyword)
});
};
const handleAddTitle = (title: string) => {
if (!editedAnalysis || !title.trim()) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: [...editedAnalysis.titleSuggestions, title.trim()]
});
};
const handleRemoveTitle = (title: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: editedAnalysis.titleSuggestions.filter(t => t !== title)
});
};
const handleUpdateOutline = (id: string | number, field: 'title' | 'segments', value: any) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
suggestedOutlines: editedAnalysis.suggestedOutlines.map(o =>
o.id === id ? { ...o, [field]: value } : o
)
});
};
const value: AnalysisPanelContextType = {
activeTab,
setActiveTab,
analysis,
estimate,
idea,
duration,
speakers,
avatarUrl,
avatarPrompt,
bible,
isEditing,
setIsEditing,
editedAnalysis,
setEditedAnalysis,
currentAnalysis,
handleRemoveKeyword,
handleAddKeyword,
handleRemoveTitle,
handleAddTitle,
handleUpdateOutline,
onRegenerate,
onUpdateAnalysis,
onUpdateBible,
};
return (
<AnalysisPanelContext.Provider value={value}>
{children}
</AnalysisPanelContext.Provider>
);
};
export const useAnalysisPanel = (): AnalysisPanelContextType => {
const context = useContext(AnalysisPanelContext);
if (!context) {
throw new Error("useAnalysisPanel must be used within AnalysisPanelProvider");
}
return context;
};

View File

@@ -0,0 +1,253 @@
import React from "react";
import { Box, Stack, Typography, Chip, Button, Divider } from "@mui/material";
import { Psychology as PsychologyIcon, Refresh as RefreshIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Mic as MicIcon } from "@mui/icons-material";
import { GlassyCard, glassyCardSx, SecondaryButton } from "../ui";
import { useAnalysisPanel, TabId } from "./AnalysisPanelContext";
import { PodcastEstimate } from "../types";
interface TabConfig {
id: TabId;
label: string;
icon: React.ReactNode;
}
const tabButtonStyles = (isActive: boolean) => ({
background: isActive
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "#f8fafc",
color: isActive ? "#fff" : "#475569",
border: isActive
? "none"
: "1px solid #e2e8f0",
borderRadius: 2.5,
px: 2.5,
py: 1.25,
fontSize: "0.8rem",
fontWeight: 600,
textTransform: "none" as const,
transition: "all 0.25s ease",
boxShadow: isActive
? "0 4px 12px rgba(102, 126, 234, 0.3)"
: "0 1px 2px rgba(0, 0, 0, 0.05)",
"&:hover": {
background: isActive
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
: "#e2e8f0",
transform: isActive ? "translateY(-1px)" : "none",
boxShadow: isActive
? "0 6px 16px rgba(102, 126, 234, 0.35)"
: "0 2px 4px rgba(0, 0, 0, 0.08)",
},
"&:active": {
transform: "translateY(0)",
},
});
export const AnalysisPanelLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const {
activeTab,
setActiveTab,
isEditing,
setIsEditing,
editedAnalysis,
setEditedAnalysis,
analysis,
estimate,
onRegenerate,
onUpdateAnalysis,
} = useAnalysisPanel();
const tabs: TabConfig[] = [
{ id: "inputs", label: "Your Inputs", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📥</Box> },
{ id: "audience", label: "Audience & Keywords", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>👥</Box> },
{ id: "outline", label: "Outline", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📋</Box> },
{ id: "details", label: "Titles, Hook & CTA", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📄</Box> },
{ id: "takeaways", label: "Takeaways", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>💡</Box> },
{ id: "guest", label: "Guest Talking Points", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>👤</Box> },
];
const handleSave = () => {
if (editedAnalysis && onUpdateAnalysis) {
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
}
setIsEditing(false);
};
const handleCancel = () => {
setIsEditing(false);
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
};
return (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#111827",
}}
>
<Stack spacing={2.5}>
{/* Header Section */}
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
<Stack direction="row" alignItems="center" gap={1.5} flex={1}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 2,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
}}
>
<PsychologyIcon sx={{ color: "#fff", fontSize: 22 }} />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: "#1e293b", fontSize: "1.1rem" }}>
Personalize Your Podcast
</Typography>
</Box>
{/* Estimate Display */}
{estimate && (
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ ml: 2 }}>
<Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', borderColor: "rgba(0,0,0,0.1)" }} />
<Typography variant="subtitle2" fontWeight={700} sx={{ color: "#4f46e5" }}>
Est. Cost: ${estimate.total.toFixed(2)}
</Typography>
{estimate.voiceName && (
<Chip
icon={<PsychologyIcon sx={{ fontSize: "12px !important" }} />}
label={estimate.voiceName}
size="small"
variant="outlined"
sx={{
height: 20,
fontSize: '0.7rem',
color: estimate.isCustomVoice ? "#10b981" : "#6366f1",
borderColor: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)",
bgcolor: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.05)" : "rgba(99, 102, 241, 0.05)",
'& .MuiChip-icon': { color: estimate.isCustomVoice ? "#10b981" : "#6366f1" }
}}
/>
)}
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
</Stack>
</Stack>
)}
</Stack>
<Stack direction="row" spacing={1.5} alignItems="center">
{/* Regenerate Button */}
<SecondaryButton
startIcon={<RefreshIcon />}
onClick={onRegenerate}
sx={{
background: "#fff",
border: "1px solid #e2e8f0",
color: "#475569",
fontWeight: 600,
fontSize: "0.8rem",
px: 2,
py: 0.75,
"&:hover": {
background: "#f8fafc",
borderColor: "#cbd5e1",
},
}}
>
Regenerate
</SecondaryButton>
{/* Edit/Save/Cancel Buttons */}
{isEditing ? (
<Stack direction="row" spacing={1}>
<Button
startIcon={<CloseIcon />}
onClick={handleCancel}
sx={{
color: "#64748b",
fontWeight: 600,
fontSize: "0.8rem",
px: 1.5,
}}
>
Cancel
</Button>
<Button
startIcon={<SaveIcon />}
variant="contained"
onClick={handleSave}
sx={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fontWeight: 600,
fontSize: "0.8rem",
px: 2,
}}
>
Save
</Button>
</Stack>
) : (
<Button
startIcon={<EditIcon />}
onClick={() => setIsEditing(true)}
sx={{
color: "#667eea",
fontWeight: 600,
fontSize: "0.8rem",
px: 1.5,
}}
>
Edit
</Button>
)}
</Stack>
</Stack>
{/* Tab Navigation */}
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{tabs.map((tab) => (
<Box
key={tab.id}
onClick={() => setActiveTab(tab.id)}
sx={tabButtonStyles(activeTab === tab.id)}
>
<Stack direction="row" spacing={1} alignItems="center">
{tab.icon}
<Box>{tab.label}</Box>
</Stack>
</Box>
))}
</Stack>
{/* Content Area - Render children (tab content) */}
<Box sx={{ mt: 1 }}>
{children}
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,8 @@
export { AnalysisPanelLayout } from "./AnalysisPanelLayout";
export { AnalysisPanelProvider, useAnalysisPanel } from "./AnalysisPanelContext";
export { AnalysisPanelInputsTab } from "./parts/AnalysisPanelInputsTab";
export { AnalysisPanelAudienceTab } from "./parts/AnalysisPanelAudienceTab";
export { AnalysisPanelOutlineTab } from "./parts/AnalysisPanelOutlineTab";
export { AnalysisPanelDetailsTab } from "./parts/AnalysisPanelDetailsTab";
export { AnalysisPanelTakeawaysTab } from "./parts/AnalysisPanelTakeawaysTab";
export { AnalysisPanelGuestTab } from "./parts/AnalysisPanelGuestTab";

View File

@@ -0,0 +1,219 @@
import React from "react";
import { Stack, Box, Typography, Chip, TextField, Divider } from "@mui/material";
import { Groups as GroupsIcon, Search as SearchIcon } from "@mui/icons-material";
import { useAnalysisPanel } from "../AnalysisPanelContext";
const inputStyles = {
'& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 },
'& .MuiInputLabel-root': { color: '#4b5563 !important' },
'& .MuiOutlinedInput-root': {
bgcolor: '#ffffff !important',
'& fieldset': { borderColor: '#d1d5db !important' },
'&:hover fieldset': { borderColor: '#4f46e5 !important' },
'&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' },
},
};
const AnalysisTabContent: React.FC<{ title: string; icon?: React.ReactNode; children: React.ReactNode }> = ({ title, icon, children }) => (
<Box sx={{ p: 2 }}>
<Stack direction="row" spacing={1.5} alignItems="center" mb={2}>
{icon && <Box sx={{ color: "#6366f1" }}>{icon}</Box>}
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
{title}
</Typography>
</Stack>
{children}
</Box>
);
export const AnalysisPanelAudienceTab: React.FC = () => {
const { currentAnalysis, isEditing, setEditedAnalysis, editedAnalysis, handleRemoveKeyword, handleAddKeyword, handleRemoveTitle, handleAddTitle } = useAnalysisPanel();
if (!currentAnalysis) {
return (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body1" sx={{ color: "#64748b" }}>
No analysis data available. Please generate analysis first.
</Typography>
</Box>
);
}
const analysis = currentAnalysis;
const handleAudienceChange = (value: string) => {
if (editedAnalysis) {
setEditedAnalysis({ ...editedAnalysis, audience: value });
}
};
const handleContentTypeChange = (value: string) => {
if (editedAnalysis) {
setEditedAnalysis({ ...editedAnalysis, contentType: value });
}
};
return (
<AnalysisTabContent title="Target Audience" icon={<GroupsIcon />}>
<Stack spacing={3}>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Audience Description
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={2}
size="small"
value={analysis.audience || ""}
onChange={(e) => handleAudienceChange(e.target.value)}
placeholder="Describe your target audience..."
sx={inputStyles}
/>
) : (
<Typography variant="body2" sx={{ color: "#0f172a" }}>
{analysis.audience}
</Typography>
)}
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
Content Type
</Typography>
{isEditing ? (
<TextField
fullWidth
size="small"
value={analysis.contentType || ""}
onChange={(e) => handleContentTypeChange(e.target.value)}
placeholder="e.g. Interview, Narrative, Solo..."
sx={inputStyles}
/>
) : (
<Chip label={analysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#4f46e5", border: "1px solid rgba(79,70,229,0.2)" }} />
)}
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
Top Keywords
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
{analysis.topKeywords?.map((k: string) => (
<Chip
key={k}
label={k}
size="small"
variant="outlined"
onDelete={isEditing ? () => handleRemoveKeyword?.(k) : undefined}
sx={{
borderColor: isEditing ? "#ef4444" : "rgba(0,0,0,0.15)",
color: isEditing ? "#dc2626" : "#0f172a",
background: isEditing ? "#fef2f2" : "#f8fafc",
fontWeight: 500,
"& .MuiChip-deleteIcon": {
color: "#ef4444",
"&:hover": {
color: "#dc2626",
backgroundColor: "#fee2e2",
},
},
}}
/>
))}
</Stack>
{isEditing && (
<TextField
fullWidth
size="small"
placeholder="Add keyword and press Enter..."
sx={inputStyles}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
handleAddKeyword?.(input.value);
input.value = '';
}
}}
/>
)}
</Box>
{analysis.exaSuggestedConfig && (
<Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Exa Research Config
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap>
{analysis.exaSuggestedConfig.exa_search_type && (
<Chip label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
)}
{analysis.exaSuggestedConfig.exa_category && (
<Chip label={`Category: ${analysis.exaSuggestedConfig.exa_category}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
)}
{analysis.exaSuggestedConfig.date_range && (
<Chip label={`Date: ${analysis.exaSuggestedConfig.date_range}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
)}
{analysis.exaSuggestedConfig.max_sources && (
<Chip label={`Max: ${analysis.exaSuggestedConfig.max_sources}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
)}
</Stack>
</Box>
)}
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
Title Suggestions
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
{analysis.titleSuggestions?.map((t: string) => (
<Chip
key={t}
label={t}
size="small"
onDelete={isEditing ? () => handleRemoveTitle?.(t) : undefined}
sx={{
color: isEditing ? "#dc2626" : "#0f172a",
background: isEditing ? "#fef2f2" : "#f8fafc",
border: isEditing ? "1px solid #ef4444" : "1px solid #e2e8f0",
maxWidth: "100%",
whiteSpace: "normal",
fontWeight: 500,
"& .MuiChip-deleteIcon": {
color: "#ef4444",
"&:hover": {
color: "#dc2626",
backgroundColor: "#fee2e2",
},
},
height: "auto",
}}
/>
))}
</Stack>
{isEditing && (
<TextField
fullWidth
size="small"
placeholder="Add title suggestion..."
sx={inputStyles}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
handleAddTitle?.(input.value);
input.value = '';
}
}}
/>
)}
</Box>
</Stack>
</AnalysisTabContent>
);
};

View File

@@ -0,0 +1,143 @@
import React from "react";
import { Stack, Box, Typography, Chip, TextField, IconButton, Paper, Divider } from "@mui/material";
import { EditNote as EditNoteIcon, Add as AddIcon, AutoAwesome as AutoAwesomeIcon, CallToAction as CTAIcon } from "@mui/icons-material";
import { useAnalysisPanel } from "../AnalysisPanelContext";
const inputStyles = {
'& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 },
'& .MuiInputLabel-root': { color: '#4b5563 !important' },
'& .MuiOutlinedInput-root': {
bgcolor: '#ffffff !important',
'& fieldset': { borderColor: '#d1d5db !important' },
'&:hover fieldset': { borderColor: '#4f46e5 !important' },
'&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' },
},
};
export const AnalysisPanelDetailsTab: React.FC = () => {
const { currentAnalysis, isEditing, handleAddTitle, handleRemoveTitle } = useAnalysisPanel();
if (!currentAnalysis) {
return (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body1" sx={{ color: "#64748b" }}>
No analysis data available. Please generate analysis first.
</Typography>
</Box>
);
}
const analysis = currentAnalysis;
return (
<Box sx={{ p: 2 }}>
<Stack spacing={4}>
{/* Titles Section */}
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<EditNoteIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
Episode Titles
</Typography>
</Stack>
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 1 }}>
{analysis.titleSuggestions?.map((title: string, idx: number) => (
<Chip
key={idx}
label={title}
size="small"
onDelete={isEditing ? () => handleRemoveTitle?.(title) : undefined}
sx={{
color: isEditing ? "#dc2626" : "#0f172a",
background: isEditing ? "#fef2f2" : "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
border: isEditing ? "1px solid #ef4444" : "1px solid #e2e8f0",
maxWidth: "100%",
whiteSpace: "normal",
height: "auto",
py: 0.5,
fontWeight: 500,
"& .MuiChip-deleteIcon": {
color: "#ef4444",
"&:hover": {
color: "#dc2626",
backgroundColor: "#fee2e2",
},
},
"&:hover": { background: isEditing ? "#fee2e2" : "#e2e8f0" },
}}
/>
))}
</Stack>
{isEditing && (
<TextField
fullWidth
size="small"
placeholder="Add title suggestion..."
sx={{ ...inputStyles, mt: 2 }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
handleAddTitle?.(input.value);
input.value = '';
}
}}
/>
)}
</Box>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
{/* Hook Section */}
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<AutoAwesomeIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
Episode Hook
</Typography>
</Stack>
{analysis.episode_hook ? (
<Paper elevation={0} sx={{ p: 2.5, bgcolor: "#f0f9ff", border: "1px solid rgba(59,130,246,0.2)", borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: "#0369a1", fontStyle: "italic", lineHeight: 1.6 }}>
"{analysis.episode_hook}"
</Typography>
</Paper>
) : (
<Typography variant="body2" sx={{ color: "#94a3b8", fontStyle: "italic" }}>
No episode hook generated yet.
</Typography>
)}
<Typography variant="caption" sx={{ color: "#94a3b8", mt: 1, display: "block" }}>
A 15-30 second opening hook to grab listener attention.
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
{/* CTA Section */}
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<CTAIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
Listener CTA
</Typography>
</Stack>
{analysis.listener_cta ? (
<Paper elevation={0} sx={{ p: 2.5, bgcolor: "#fff7ed", border: "1px solid rgba(249,115,22,0.2)", borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: "#c2410c", fontWeight: 500, lineHeight: 1.6 }}>
{analysis.listener_cta}
</Typography>
</Paper>
) : (
<Typography variant="body2" sx={{ color: "#94a3b8", fontStyle: "italic" }}>
No listener call-to-action generated yet.
</Typography>
)}
<Typography variant="caption" sx={{ color: "#94a3b8", mt: 1, display: "block" }}>
A call-to-action for listeners after the episode.
</Typography>
</Box>
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
import { Quiz as TalkIcon } from "@mui/icons-material";
import { useAnalysisPanel } from "../AnalysisPanelContext";
export const AnalysisPanelGuestTab: React.FC = () => {
const { analysis: ctxAnalysis } = useAnalysisPanel();
const guestTalkingPoints = ctxAnalysis?.guest_talking_points;
if (!guestTalkingPoints || guestTalkingPoints.length === 0) {
return (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body1" sx={{ color: "#64748b" }}>
No guest talking points generated yet. Add a guest speaker to get interview questions.
</Typography>
</Box>
);
}
return (
<Box sx={{ p: 2 }}>
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
<TalkIcon sx={{ color: "#6366f1" }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
Guest Talking Points
</Typography>
</Box>
<Stack spacing={2}>
{guestTalkingPoints.map((point: string, idx: number) => (
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#faf5ff", border: "1px solid rgba(168,85,247,0.2)", borderRadius: 2, display: "flex", alignItems: "flex-start", gap: 1.5 }}>
<Chip label="Q" size="small" sx={{ minWidth: 24, bgcolor: "#a855f7", color: "#fff" }} />
<Typography variant="body2" sx={{ color: "#6b21a8" }}>
{point}
</Typography>
</Paper>
))}
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,130 @@
import React from "react";
import { Box, Stack, Typography, Chip, Paper, alpha } from "@mui/material";
import { Input as InputIcon, Mic as MicIcon } from "@mui/icons-material";
import { useAnalysisPanel } from "../AnalysisPanelContext";
interface AnalysisTabContentProps {
title: string;
icon?: React.ReactNode;
children: React.ReactNode;
}
const AnalysisTabContent: React.FC<AnalysisTabContentProps> = ({ title, icon, children }) => (
<Box sx={{ p: 2 }}>
<Stack direction="row" spacing={1.5} alignItems="center" mb={2}>
{icon && <Box sx={{ color: "#6366f1" }}>{icon}</Box>}
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
{title}
</Typography>
</Stack>
{children}
</Box>
);
export const AnalysisPanelInputsTab: React.FC = () => {
const { idea, duration, speakers, avatarUrl, avatarPrompt, estimate } = useAnalysisPanel();
if (!idea && !duration && !speakers && !avatarUrl && !avatarPrompt) {
return (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body1" sx={{ color: "#64748b" }}>
No analysis data available. Please generate analysis first.
</Typography>
</Box>
);
}
return (
<AnalysisTabContent title="Your Inputs" icon={<InputIcon />}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
gap: 3,
alignItems: "flex-start",
}}
>
<Stack spacing={1.5}>
{idea && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Podcast Idea
</Typography>
<Typography variant="body2" sx={{ color: "#0f172a", wordBreak: "break-word" }}>
{idea}
</Typography>
</Box>
)}
<Stack direction="row" spacing={2} flexWrap="wrap">
{estimate?.voiceName && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Voice
</Typography>
<Chip
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
label={estimate.voiceName}
size="small"
sx={{
background: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
color: estimate.isCustomVoice ? "#10b981" : "#6366f1",
border: `1px solid ${estimate.isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
'& .MuiChip-icon': { color: estimate.isCustomVoice ? "#10b981" : "#6366f1" }
}}
/>
</Box>
)}
{duration !== undefined && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Duration
</Typography>
<Chip
label={`${duration} minutes`}
size="small"
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
/>
</Box>
)}
{speakers !== undefined && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Speakers
</Typography>
<Chip
label={speakers === 1 ? "Solo" : `${speakers} speakers`}
size="small"
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
/>
</Box>
)}
</Stack>
</Stack>
{avatarUrl && (
<Paper sx={{ p: 2, background: "#f8fafc", border: "1px solid rgba(0,0,0,0.08)" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
Avatar Preview
</Typography>
<Box
component="img"
src={avatarUrl}
alt="Avatar"
sx={{
width: "100%",
maxWidth: 120,
height: "auto",
borderRadius: 2,
border: "1px solid rgba(0,0,0,0.1)",
}}
/>
{avatarPrompt && (
<Typography variant="caption" sx={{ color: "#64748b", mt: 1, display: "block" }}>
Prompt: {avatarPrompt}
</Typography>
)}
</Paper>
)}
</Box>
</AnalysisTabContent>
);
};

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Box, Typography, Chip } from "@mui/material";
import { useAnalysisPanel } from "../AnalysisPanelContext";
const AnalysisTabContent: React.FC<{ title: string; icon?: React.ReactNode; children: React.ReactNode }> = ({ title, icon, children }) => (
<Box sx={{ p: 2 }}>
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
{icon}
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
{title}
</Typography>
</Box>
{children}
</Box>
);
export const AnalysisPanelOutlineTab: React.FC = () => {
const { currentAnalysis, isEditing, handleUpdateOutline } = useAnalysisPanel();
if (!currentAnalysis || !currentAnalysis.suggestedOutlines) {
return (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body1" sx={{ color: "#64748b" }}>
No outline available. Please generate analysis first.
</Typography>
</Box>
);
}
const analysis = currentAnalysis;
return (
<Box sx={{ p: 2 }}>
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
Episode Outline
</Typography>
</Box>
{analysis.suggestedOutlines?.map((outline: { id?: string | number; title: string; segments: string[] }, idx: number) => (
<Box key={outline.id || idx} sx={{ p: 2, bgcolor: "#f8fafc", borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)", mb: 2 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 700, mb: 1.5 }}>
Option {idx + 1}: {outline.title}
</Typography>
{outline.segments?.map((segment: string, sIdx: number) => (
<Box key={sIdx} sx={{ display: "flex", alignItems: "flex-start", gap: 1, mb: 1 }}>
<Chip label={sIdx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
<Typography variant="body2" sx={{ color: "#475569" }}>
{segment}
</Typography>
</Box>
))}
</Box>
))}
</Box>
);
};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
import { Lightbulb as TipsIcon } from "@mui/icons-material";
import { useAnalysisPanel } from "../AnalysisPanelContext";
export const AnalysisPanelTakeawaysTab: React.FC = () => {
const { analysis: ctxAnalysis } = useAnalysisPanel();
const keyTakeaways = ctxAnalysis?.key_takeaways;
if (!keyTakeaways || keyTakeaways.length === 0) {
return (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body1" sx={{ color: "#64748b" }}>
No key takeaways generated yet.
</Typography>
</Box>
);
}
return (
<Box sx={{ p: 2 }}>
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
<TipsIcon sx={{ color: "#6366f1" }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
Key Takeaways
</Typography>
</Box>
<Stack spacing={2}>
{keyTakeaways.map((takeaway: string, idx: number) => (
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#f0fdf4", border: "1px solid rgba(34,197,94,0.2)", borderRadius: 2, display: "flex", alignItems: "flex-start", gap: 1.5 }}>
<Chip label={idx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#22c55e", color: "#fff" }} />
<Typography variant="body2" sx={{ color: "#166534" }}>
{takeaway}
</Typography>
</Paper>
))}
</Stack>
</Box>
);
};

View File

@@ -7,6 +7,8 @@ interface InputsTabProps {
idea?: string;
duration?: number;
speakers?: number;
voiceName?: string;
podcastMode?: "audio_only" | "video_only" | "audio_video";
avatarUrl?: string | null;
avatarPrompt?: string | null;
avatarBlobUrl?: string | null;
@@ -14,8 +16,8 @@ interface InputsTabProps {
avatarError?: boolean;
}
export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers, avatarUrl, avatarPrompt, avatarBlobUrl, avatarLoading, avatarError }) => {
if (!idea && !duration && !speakers && !avatarUrl && !avatarPrompt) {
export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers, voiceName, podcastMode, avatarUrl, avatarPrompt, avatarBlobUrl, avatarLoading, avatarError }) => {
if (!idea && !duration && !speakers && !voiceName && !podcastMode && !avatarUrl && !avatarPrompt) {
return null;
}
@@ -24,7 +26,7 @@ export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers,
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
gridTemplateColumns: { xs: "1fr", md: avatarUrl && podcastMode !== "audio_only" ? "1fr 1fr" : "1fr" },
gap: 3,
alignItems: "flex-start",
}}
@@ -65,6 +67,38 @@ export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers,
/>
</Box>
)}
{voiceName && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Voice
</Typography>
<Chip
label={voiceName}
size="small"
sx={{ background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "#fff", fontWeight: 600 }}
/>
</Box>
)}
{podcastMode && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Podcast Mode
</Typography>
<Chip
label={podcastMode === "audio_only" ? "Audio Only" : podcastMode === "video_only" ? "Video" : "Audio + Video"}
size="small"
sx={{
background: podcastMode === "audio_only"
? "#10b981"
: podcastMode === "video_only"
? "#f97316"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "#fff",
fontWeight: 600,
}}
/>
</Box>
)}
</Stack>
{avatarPrompt && (
@@ -112,7 +146,7 @@ export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers,
)}
</Stack>
{avatarUrl && (
{podcastMode !== "audio_only" && avatarUrl && (
<Box>
<Typography
variant="caption"

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from "react";
import { Stack, Paper, Box } from "@mui/material";
import { CreateProjectPayload, Knobs } from "./types";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Stack, Paper, Box, Chip, Typography } from "@mui/material";
import { CreateProjectPayload, Knobs, PodcastMode } from "./types";
import { useSubscription } from "../../contexts/SubscriptionContext";
import { podcastApi } from "../../services/podcastApi";
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
@@ -26,9 +26,10 @@ interface CreateModalProps {
defaultKnobs: Knobs;
isSubmitting?: boolean;
announcement?: string;
onAnnouncementClear?: () => void;
}
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement }) => {
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement, onAnnouncementClear }) => {
const { subscription } = useSubscription();
const [topicInput, setTopicInput] = useState("");
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
@@ -50,6 +51,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
const [brandAvatarFromDb, setBrandAvatarFromDb] = useState<string | null>(null);
const [cameraSelfieOpen, setCameraSelfieOpen] = useState(false);
const [podcastMode, setPodcastMode] = useState<PodcastMode>("audio_video");
// Enhanced topic choices state
const [enhancedChoices, setEnhancedChoices] = useState<string[]>([]);
@@ -286,11 +288,15 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const hasDuration = Boolean(duration > 0 && duration <= 10);
const hasSpeakers = Boolean(speakers >= 1 && speakers <= 2);
const canSubmit = Boolean(hasTopic && hasAvatar && hasVoice && hasDuration && hasSpeakers);
const canSubmit = Boolean(hasTopic && (podcastMode === "audio_only" || hasAvatar) && hasVoice && hasDuration && hasSpeakers);
const submit = async () => {
const [submitError, setSubmitError] = useState<string | null>(null);
const submit = useCallback(async () => {
if (!canSubmit || isSubmitting) return;
setSubmitError(null);
// Determine if input is idea or URL
// For URL, we extract the first URL found or use the whole string if it's a direct URL
let finalIdea = "";
@@ -332,16 +338,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
voice_id: selectedVoiceId,
};
onCreate({
ideaOrUrl: finalUrl || finalIdea,
speakers,
duration,
knobs: finalKnobs,
budgetCap,
files: { voiceFile, avatarFile },
avatarUrl: finalAvatarUrl,
});
};
try {
await onCreate({
ideaOrUrl: finalUrl || finalIdea,
speakers,
duration,
knobs: finalKnobs,
budgetCap,
files: { voiceFile, avatarFile },
avatarUrl: finalAvatarUrl,
podcastMode,
});
} catch (err: any) {
console.error("[CreateModal] Submit error:", err);
setSubmitError(err?.message || String(err) || "Failed to create project");
}
}, [canSubmit, isSubmitting, isUrl, topicInput, avatarFile, avatarUrl, knobs, selectedVoiceId, speakers, duration, budgetCap, podcastMode, onCreate]);
const reset = () => {
setTopicInput("");
@@ -358,6 +370,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setKnobs({ ...defaultKnobs });
setSelectedVoiceId("Wise_Woman");
setPlaceholderIndex(0);
setPodcastMode("audio_video");
};
const handleAvatarSelectFromLibrary = React.useCallback((url: string) => {
@@ -560,6 +573,8 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setDuration={setDuration}
speakers={speakers}
setSpeakers={setSpeakers}
podcastMode={podcastMode}
setPodcastMode={setPodcastMode}
/>
</Box>
</Stack>
@@ -583,6 +598,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
brandAvatarBlobUrl={brandAvatarBlobUrl}
cameraSelfieOpen={cameraSelfieOpen}
setCameraSelfieOpen={setCameraSelfieOpen}
podcastMode={podcastMode}
/>
<VoiceSelector
@@ -597,6 +613,8 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
canSubmit={canSubmit}
isSubmitting={isSubmitting}
announcement={announcement}
onAnnouncementClear={onAnnouncementClear}
error={submitError}
/>
{/* Enhanced Topic Choices Modal */}

View File

@@ -14,6 +14,7 @@ import {
import { AvatarAssetBrowser } from "../AvatarAssetBrowser";
import { CameraSelfie } from "../CameraSelfie";
import { SecondaryButton } from "../ui";
import { PodcastMode } from "../types";
interface AvatarSelectorProps {
avatarTab: number;
@@ -34,6 +35,7 @@ interface AvatarSelectorProps {
brandAvatarBlobUrl?: string | null;
cameraSelfieOpen: boolean;
setCameraSelfieOpen: (open: boolean) => void;
podcastMode?: PodcastMode;
}
export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
@@ -55,6 +57,7 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
brandAvatarBlobUrl,
cameraSelfieOpen,
setCameraSelfieOpen,
podcastMode,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -65,6 +68,35 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
? ["Brand", "Library", "Selfie", "Upload"]
: ["Use Brand Avatar", "Asset Library", "Take Selfie", "Upload Your Photo"];
if (podcastMode === "audio_only") {
return (
<Box
sx={{
flex: 1,
minWidth: 0,
p: { xs: 1.5, sm: 2.5 },
borderRadius: 2,
background: "#f8fafc",
border: "1px dashed rgba(15, 23, 42, 0.12)",
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 200,
}}
>
<Stack spacing={1} alignItems="center" sx={{ textAlign: "center" }}>
<PersonIcon sx={{ color: "#94a3b8", fontSize: 40 }} />
<Typography variant="subtitle2" sx={{ color: "#64748b", fontWeight: 600 }}>
No avatar needed for audio-only
</Typography>
<Typography variant="caption" sx={{ color: "#94a3b8" }}>
Avatar is only used for video podcasts. Switch to Video or Both to enable.
</Typography>
</Stack>
</Box>
);
}
return (
<Box
sx={{

View File

@@ -0,0 +1,317 @@
import React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
IconButton,
Typography,
Stack,
Box,
Divider,
alpha,
} from "@mui/material";
import { Close as CloseIcon, Mic as MicIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Settings as SettingsIcon } from "@mui/icons-material";
export const DurationInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
}
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Episode Duration Guide
</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ opacity: 0.8, mt: 1 }}>
Recommended durations based on content type and audience
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={2.5}>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#4ade80' }}>
Recommended Durations
</Typography>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)" }}>1-3 minutes</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>Quick tips Social media Teasers</Typography>
</Stack>
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)" }}>5-10 minutes</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>Standard podcast Deep dives</Typography>
</Stack>
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#fb923c' }}>
Cost vs Duration
</Typography>
<Stack spacing={1}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>1-3 min:</strong> $0.50 - $2.00 (Audio) / $3 - $6 (Video)
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>5 min:</strong> $1 - $3 (Audio) / $5 - $12 (Video)
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>10 min:</strong> $2 - $6 (Audio) / $10 - $20 (Video)
</Typography>
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#a78bfa' }}>
💡 Pro Tips
</Typography>
<Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Start short (1-3 min) for YouTube algorithm
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Deep content works best at 5-10 minutes
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Longest single call: 10 minutes max
</Typography>
</Stack>
</Box>
</Stack>
</DialogContent>
</Dialog>
);
};
export const SpeakersInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
}
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
👥 Number of Speakers Guide
</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ opacity: 0.8, mt: 1 }}>
Choose the right format for your content
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={2.5}>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#4ade80' }}>
🎤 1 Speaker (Solo)
</Typography>
<Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Best for: Tutorials, tips, personal stories
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Simpler script Lower cost
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Full creative control
</Typography>
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#fb923c' }}>
👥 2 Speakers (Host + Guest)
</Typography>
<Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Best for: Interviews, debates, Q&A
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
More engaging Broader perspectives
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Requires guest coordination
</Typography>
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#a78bfa' }}>
💡 Production Notes
</Typography>
<Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
2 speakers = 2x script sections = ~2x word count
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Audio-only mode works best for interviews
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Video mode requires avatar setup for each speaker
</Typography>
</Stack>
</Box>
</Stack>
</DialogContent>
</Dialog>
);
};
export const VoiceInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
}
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
🎤 Voice Selection Guide
</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ opacity: 0.8, mt: 1 }}>
Choose the right voice for your podcast
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={2.5}>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<AutoAwesomeIcon sx={{ color: '#4ade80', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#4ade80' }}>
Voice Clone
</Typography>
</Stack>
<Stack spacing={1}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Your own voice - cloned from audio sample
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Most authentic and personalized
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Requires voice cloning setup in settings
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Best for: Brand podcasts, testimonials
</Typography>
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<MicIcon sx={{ color: '#fb923c', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#fb923c' }}>
System Voices
</Typography>
</Stack>
<Stack spacing={1}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>Female:</strong> Wise Woman, Friendly, Calm, Lively, Inspirational
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>Male:</strong> Deep Voice, Casual, Patient, Determined
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Instant selection - no setup required
</Typography>
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<PersonIcon sx={{ color: '#a78bfa', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#a78bfa' }}>
Voice Personalities
</Typography>
</Stack>
<Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>Professional:</strong> Corporate, educational content
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>Happy/Energetic:</strong> Entertainment, announcements
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>Calm:</strong> Meditation, sensitive topics
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
<strong>Storytelling:</strong> Narratives, books, experiences
</Typography>
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<SettingsIcon sx={{ color: '#38bdf8', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#38bdf8' }}>
💡 Tips
</Typography>
</Stack>
<Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Match voice personality to your content type
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Use preview button to hear each voice
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
Filter by gender/mood to find voices faster
</Typography>
</Stack>
</Box>
</Stack>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,25 +1,27 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Stack,
Alert,
Typography,
Chip,
Tooltip,
Alert,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Box,
alpha,
Collapse,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
List,
ListItem,
ListItemIcon,
ListItemText,
CircularProgress,
Box,
LinearProgress,
Chip,
Divider,
useMediaQuery,
useTheme,
Collapse,
} from "@mui/material";
import {
Info as InfoIcon,
@@ -38,8 +40,9 @@ import {
Headphones as HeadphonesIcon,
Article as ArticleIcon,
Campaign as CampaignIcon,
Groups as GroupsIcon,
Groups as GroupsIcon,
School as SchoolIcon,
Error as ErrorIcon,
} from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
@@ -49,6 +52,8 @@ interface CreateActionsProps {
canSubmit: boolean;
isSubmitting: boolean;
announcement?: string;
onAnnouncementClear?: () => void;
error?: string | null;
}
const ANALYSIS_FEATURES = [
@@ -374,7 +379,7 @@ const WhatYoullGetView: React.FC<{ isMobile?: boolean }> = ({ isMobile }) => (
</>
);
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting, announcement }) => {
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting, announcement, onAnnouncementClear, error }) => {
const [showInfo, setShowInfo] = useState(true);
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [analysisStarted, setAnalysisStarted] = useState(false);
@@ -387,6 +392,31 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
return () => clearTimeout(timer);
}, []);
// Close modal when analysis completes OR when there's an error
// Use a ref to track previous isSubmitting to detect the transition from true to false
const prevIsSubmittingRef = useRef(isSubmitting);
useEffect(() => {
// Detect transition from submitting to not submitting (analysis complete)
const wasSubmitting = prevIsSubmittingRef.current;
const nowNotSubmitting = !isSubmitting;
if (showAnalysisModal && analysisStarted && wasSubmitting && nowNotSubmitting) {
console.warn('[CreateActions] Analysis complete — closing modal and clearing announcement');
setTimeout(() => {
setShowAnalysisModal(false);
onAnnouncementClear?.();
}, 100);
}
// Update ref for next render
prevIsSubmittingRef.current = isSubmitting;
// If there's an error, also ensure modal is usable
if (error && showAnalysisModal) {
console.warn('[CreateActions] Error detected:', error);
}
}, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error]);
// Sequential progress - increment every few seconds
useEffect(() => {
if (!isSubmitting || !analysisStarted) {
@@ -408,11 +438,11 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
if (canSubmit && !isSubmitting) setShowAnalysisModal(true);
};
const handleStartAnalysis = () => {
const handleStartAnalysis = useCallback(() => {
setAnalysisStarted(true);
setProgressIndex(0);
submit();
};
}, [submit]);
const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting);
@@ -465,6 +495,22 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
<DialogContent sx={{ ...styles.dialogContent, ...(isMobile ? { px: 2, py: 2 } : {}) }}>
{showProgressInModal ? (
<AnalysisProgressView currentMessage={announcement} progressIndex={progressIndex} />
) : error ? (
<Stack spacing={2}>
<Alert
severity="error"
icon={<ErrorIcon />}
sx={{ bgcolor: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.3)", color: "#fecaca" }}
>
<Typography variant="body2" fontWeight={600} sx={{ color: "#fecaca" }}>
Error creating project
</Typography>
<Typography variant="caption" sx={{ color: "#fecaca", display: "block", mt: 1 }}>
{error}
</Typography>
</Alert>
<WhatYoullGetView isMobile={isMobile} />
</Stack>
) : (
<WhatYoullGetView isMobile={isMobile} />
)}

View File

@@ -1,12 +1,16 @@
import React from "react";
import { Stack, Box, Typography, TextField, ToggleButton, ToggleButtonGroup, alpha } from "@mui/material";
import { Person as PersonIcon, Group as GroupIcon, Settings as SettingsIcon } from "@mui/icons-material";
import React, { useState } from "react";
import { Stack, Box, Typography, TextField, ToggleButton, ToggleButtonGroup, alpha, IconButton, Tooltip } from "@mui/material";
import { Person as PersonIcon, Group as GroupIcon, Settings as SettingsIcon, HelpOutline as HelpOutlineIcon, Headphones as HeadphonesIcon, Videocam as VideocamIcon } from "@mui/icons-material";
import { PodcastMode } from "../types";
import { PodcastModeInfoModal } from "./PodcastModeInfoModal";
interface PodcastConfigurationProps {
duration: number;
setDuration: (value: number) => void;
speakers: number;
setSpeakers: (value: number) => void;
podcastMode: PodcastMode;
setPodcastMode: (mode: PodcastMode) => void;
}
export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
@@ -14,7 +18,11 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
setDuration,
speakers,
setSpeakers,
podcastMode,
setPodcastMode,
}) => {
const [modeInfoOpen, setModeInfoOpen] = useState(false);
const handleDurationChange = (value: number) => {
const clamped = Math.min(10, Math.max(1, value));
setDuration(clamped);
@@ -29,6 +37,21 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
}
};
const handleModeChange = (
event: React.MouseEvent<HTMLElement>,
newValue: PodcastMode | null
) => {
if (newValue !== null) {
setPodcastMode(newValue);
}
};
const podcastModes: { value: PodcastMode; label: string; icon: React.ReactNode; color: string; desc: string }[] = [
{ value: "audio_only", label: "Audio", icon: <HeadphonesIcon fontSize="small" />, color: "#10b981", desc: "Audio podcast only" },
{ value: "video_only", label: "Video", icon: <VideocamIcon fontSize="small" />, color: "#f97316", desc: "AI avatar video" },
{ value: "audio_video", label: "Both", icon: <><HeadphonesIcon fontSize="small" /><VideocamIcon fontSize="small" /></>, color: "#8b5cf6", desc: "Audio + Video" },
];
return (
<Box
sx={{
@@ -98,6 +121,70 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
</Stack>
<Stack spacing={3}>
{/* Podcast Mode */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ display: "block", color: "#64748b", fontWeight: 600, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Podcast Mode
</Typography>
<Tooltip title="Learn about podcast modes">
<IconButton size="small" onClick={() => setModeInfoOpen(true)} sx={{ color: "#94a3b8", p: 0.25, "&:hover": { color: "#667eea" } }}>
<HelpOutlineIcon sx={{ fontSize: "0.9rem" }} />
</IconButton>
</Tooltip>
</Stack>
<ToggleButtonGroup
value={podcastMode}
exclusive
onChange={handleModeChange}
fullWidth
size="small"
sx={{
backgroundColor: "#f8fafc",
border: "2px solid rgba(102, 126, 234, 0.2)",
borderRadius: 2,
p: 0.5,
"& .MuiToggleButton-root": {
border: "none",
borderRadius: 1.5,
color: "#64748b",
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
py: 1,
transition: "all 0.2s ease",
"&:hover": {
backgroundColor: alpha("#667eea", 0.08),
},
"&.Mui-selected": {
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "#ffffff",
fontWeight: 600,
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
},
},
}}
>
{podcastModes.map((mode) => (
<ToggleButton key={mode.value} value={mode.value} aria-label={mode.label}>
<Stack direction="row" spacing={0.5} alignItems="center">
{mode.icon}
<Typography variant="body2">{mode.label}</Typography>
</Stack>
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="caption" sx={{ display: "block", mt: 1, color: podcastModes.find(m => m.value === podcastMode)?.color || "#64748b", fontSize: "0.75rem", fontWeight: 500 }}>
{podcastModes.find(m => m.value === podcastMode)?.desc}
{podcastMode === "audio_only" && " • No avatar needed • Lowest cost"}
{podcastMode === "video_only" && " • Requires avatar • Medium cost"}
{podcastMode === "audio_video" && " • Both formats • Highest cost"}
</Typography>
</Box>
{/* Duration Input */}
<Box>
<Typography variant="caption" sx={{ display: "block", mb: 1, color: "#64748b", fontWeight: 600, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
@@ -201,6 +288,8 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
</Typography>
</Box>
</Stack>
<PodcastModeInfoModal open={modeInfoOpen} onClose={() => setModeInfoOpen(false)} />
</Box>
);
};

View File

@@ -0,0 +1,236 @@
import React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
IconButton,
Box,
Typography,
Stack,
Chip,
Divider,
alpha,
} from "@mui/material";
import { Close as CloseIcon, HelpOutline as HelpOutlineIcon } from "@mui/icons-material";
export const PodcastModeInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
}
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
Choosing Your Podcast Mode
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8 }}>
Understand cost, duration, and best use cases for each mode
</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ opacity: 0.7, mt: 1 }}>
Select the right podcast mode based on your content type, target audience, and budget.
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Mode Overview */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
🎙 Podcast Modes Explained
</Typography>
<Box sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</Box>
</Stack>
<Stack spacing={1.5}>
{[
{ mode: 'audio_only', icon: '🎧', label: 'Audio Only', desc: 'AI voice podcast. No video. Best for audio platforms.',
bg: '#f0fdf4', borderColor: '#10b981', textColor: '#166534' },
{ mode: 'video_only', icon: '🎬', label: 'Video Only', desc: 'AI avatar video. Best for YouTube and social media.',
bg: '#fff7ed', borderColor: '#f97316', textColor: '#9a3412' },
{ mode: 'audio_video', icon: '🎧+🎬', label: 'Both (Audio + Video)', desc: 'Generates both versions. Best for multi-platform distribution.',
bg: '#f5f3ff', borderColor: '#8b5cf6', textColor: '#6b21a8' },
].map((item) => (
<Box
key={item.mode}
sx={{
p: 2,
borderRadius: 2,
border: `1px solid ${item.borderColor}`,
background: alpha(item.bg, 0.95),
opacity: 0.95
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Typography sx={{ fontSize: '1.5rem' }}>{item.icon}</Typography>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: item.textColor }}>
{item.label}
</Typography>
<Typography variant="body2" sx={{ color: item.textColor, fontSize: '0.875rem', opacity: 0.9 }}>
{item.desc}
</Typography>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.2)" }} />
{/* AI API Costs */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
💰 AI API Costs (Estimated per 5-min episode)
</Typography>
</Stack>
<Box sx={{
p: 2,
background: alpha("#ffffff", 0.1),
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)'
}}>
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'white' }}>Audio Only</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>TTS API (voice generation)</Typography>
</Box>
<Chip label="$0.50 - $1.50" size="small" sx={{ background: '#10b981', color: '#fff', fontWeight: 600 }} />
</Stack>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'white' }}>Video Only</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>TTS + Image Generation APIs</Typography>
</Box>
<Chip label="$3.00 - $8.00" size="small" sx={{ background: '#f97316', color: '#fff', fontWeight: 600 }} />
</Stack>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'white' }}>Both</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>TTS + Image + Video combination</Typography>
</Box>
<Chip label="$4.00 - $12.00" size="small" sx={{ background: '#8b5cf6', color: '#fff', fontWeight: 600 }} />
</Stack>
</Stack>
</Box>
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block', fontStyle: 'italic' }}>
* Actual costs vary based on scene count, image resolution, and API provider
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.2)" }} />
{/* Max Duration & Optimization */}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1.5 }}>
Maximum Duration & AI Optimization
</Typography>
<Stack spacing={1.5}>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
<Chip label="Audio Only" size="small" sx={{ background: '#10b981', color: '#fff' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#4ade80' }}>
Max 3-4 scenes per episode Optimized with fewer API calls
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
Each scene: 800-1200 words (~1.5 min audio)
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
Fewer API calls = lower cost
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
Rich, content-dense script for audio-only listening
</Typography>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
<Chip label="Video Only" size="small" sx={{ background: '#f97316', color: '#fff' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#fb923c' }}>
Max 5-6 scenes per episode More visuals = more image costs
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
Each scene: 300-500 words (~30-45 sec)
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
Shorter scripts for visual pacing + image rendering time
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
More scenes = more image generation API calls
</Typography>
</Box>
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.2)" }} />
{/* When to Use */}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1.5 }}>
🎯 When to Choose Each Mode
</Typography>
<Stack spacing={1.5}>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#4ade80', mb: 1 }}>
🎧 Choose Audio Only For:
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 0.5 }}>
{['Spotify & Podcasts', 'Low budget', 'Evergreen content', 'Commute listeners', 'Deep content'].map((item) => (
<Chip key={item} label={item} size="small" variant="outlined" sx={{ borderColor: '#4ade80', color: '#4ade80' }} />
))}
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#fb923c', mb: 1 }}>
🎬 Choose Video Only For:
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 0.5 }}>
{['YouTube', 'Social Media', 'Personal Brand', 'Visual Content', 'Tutorials'].map((item) => (
<Chip key={item} label={item} size="small" variant="outlined" sx={{ borderColor: '#fb923c', color: '#fb923c' }} />
))}
</Stack>
</Box>
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#a78bfa', mb: 1 }}>
🎧+🎬 Choose Both For:
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 0.5 }}>
{['Multi-platform', 'Max Reach', 'Content Repurpose', 'Premium Podcast'].map((item) => (
<Chip key={item} label={item} size="small" variant="outlined" sx={{ borderColor: '#a78bfa', color: '#a78bfa' }} />
))}
</Stack>
</Box>
</Stack>
</Box>
</Stack>
</DialogContent>
</Dialog>
);
};

View File

@@ -112,7 +112,20 @@ const PodcastDashboard: React.FC = () => {
>
<Stack spacing={3}>
{/* Header */}
<Header onShowProjects={() => setShowProjectList(true)} onNewEpisode={handleNewEpisode} />
<Header
onShowProjects={() => setShowProjectList(true)}
onNewEpisode={handleNewEpisode}
activeStep={workflow.activeStep}
completedSteps={[
...(analysis ? [0] : []),
...(research ? [1] : []),
...(scriptData ? [2] : []),
...(renderJobs.some(j => j.status === "completed") ? [3] : []),
]}
onStepClick={(step) => {
// Handle step clicks - could navigate to different views
}}
/>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
@@ -232,6 +245,8 @@ const PodcastDashboard: React.FC = () => {
idea={project?.idea}
duration={project?.duration}
speakers={project?.speakers}
voiceName={estimate?.voiceName}
podcastMode={project?.podcastMode}
avatarUrl={project?.avatarUrl}
avatarPrompt={project?.avatarPrompt}
bible={bible}

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText } from "@mui/material";
import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText, Collapse } from "@mui/material";
import {
Mic as MicIcon,
Menu as MenuIcon,
@@ -14,13 +14,17 @@ import {
import { useNavigate } from "react-router-dom";
import { PrimaryButton } from "../ui";
import HeaderControls from "../../shared/HeaderControls";
import { ProgressStepper } from "./ProgressStepper";
interface HeaderProps {
onShowProjects: () => void;
onNewEpisode: () => void;
activeStep?: number;
completedSteps?: number[];
onStepClick?: (stepIndex: number) => void;
}
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode }) => {
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, activeStep = -1, completedSteps = [], onStepClick }) => {
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const isMenuOpen = Boolean(anchorEl);
@@ -230,6 +234,17 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
</Menu>
</Stack>
</Stack>
{/* Progress Stepper - integrated into header when active */}
<Collapse in={activeStep >= 0} timeout={400}>
<Box sx={{ mt: 1.5 }}>
<ProgressStepper
activeStep={activeStep}
completedSteps={completedSteps}
onStepClick={onStepClick}
/>
</Box>
</Collapse>
</Box>
);
};

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Box, Paper, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
import { Box, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
import {
Psychology as PsychologyIcon,
Search as SearchIcon,
@@ -10,22 +10,21 @@ import {
interface ProgressStepperProps {
activeStep: number;
completedSteps?: number[]; // Steps that have been completed (have data)
completedSteps?: number[];
onStepClick?: (stepIndex: number) => void;
}
const steps = [
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
{ label: "Analyze", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
{ label: "Gather", icon: <SearchIcon />, description: "Gather facts and citations" },
{ label: "Write", icon: <EditNoteIcon />, description: "Edit and approve script" },
{ label: "Produce", icon: <PlayArrowIcon />, description: "Generate audio & video" },
];
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
if (activeStep < 0) return null;
const handleStepClick = (stepIndex: number) => {
// Allow navigation to any completed step (has data), not just steps before active step
const isCompleted = completedSteps.includes(stepIndex);
if (isCompleted && onStepClick) {
onStepClick(stepIndex);
@@ -33,73 +32,60 @@ export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, co
};
return (
<Paper
sx={{
p: 2.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 2,
}}
>
<Stepper activeStep={activeStep} orientation="horizontal">
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(index);
const isClickable = isCompleted && onStepClick !== undefined;
return (
<Step key={step.label} completed={isCompleted}>
<StepLabel
onClick={() => handleStepClick(index)}
sx={{
cursor: isClickable ? "pointer" : "default",
"&:hover": isClickable
? {
"& .MuiStepLabel-label": {
color: "#667eea",
},
}
: {},
}}
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: completed
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: active
? alpha("#667eea", 0.15)
: "#e2e8f0",
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
color: completed || active ? "#fff" : "#64748b",
transition: "all 0.2s ease",
...(isClickable && {
cursor: "pointer",
"&:hover": {
transform: "scale(1.05)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
},
}),
}}
>
{completed ? <CheckCircleIcon /> : step.icon}
</Box>
)}
>
<Typography variant="subtitle2">{step.label}</Typography>
<Typography variant="caption" color="text.secondary">
{step.description}
</Typography>
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
);
};
<Stepper activeStep={activeStep} orientation="horizontal" sx={{ px: 1 }}>
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(index);
const isClickable = isCompleted && onStepClick !== undefined;
return (
<Step key={step.label} completed={isCompleted}>
<StepLabel
onClick={() => handleStepClick(index)}
sx={{
cursor: isClickable ? "pointer" : "default",
"&:hover": isClickable
? {
"& .MuiStepLabel-label": {
color: "#667eea",
},
}
: {},
}}
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: completed
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: active
? alpha("#667eea", 0.15)
: "#e2e8f0",
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
color: completed || active ? "#fff" : "#64748b",
transition: "all 0.2s ease",
...(isClickable && {
cursor: "pointer",
"&:hover": {
transform: "scale(1.08)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
},
}),
}}
>
{completed ? <CheckCircleIcon sx={{ fontSize: 20 }} /> : React.cloneElement(step.icon, { fontSize: "small" })}
</Box>
)}
>
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: "0.75rem" }}>{step.label}</Typography>
</StepLabel>
</Step>
);
})}
</Stepper>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import {
Stack,
Typography,
@@ -9,6 +9,7 @@ import {
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Checkbox,
FormControl,
InputLabel,
@@ -22,12 +23,33 @@ import {
DialogActions,
TextField,
IconButton,
CircularProgress,
LinearProgress,
Divider,
useMediaQuery,
useTheme,
} from "@mui/material";
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Help as HelpIcon } from "@mui/icons-material";
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Help as HelpIcon, TrendingUp as TrendingUpIcon, Psychology as PsychologyIcon, FactCheck as FactCheckIcon, MenuBook as MenuBookIcon } from "@mui/icons-material";
import { ResearchProvider } from "../../../services/blogWriterApi";
import { Query } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "../ui";
const RESEARCH_FEATURES = [
{ icon: <TrendingUpIcon />, text: "Latest trends & statistics from the web" },
{ icon: <FactCheckIcon />, text: "Verified facts with source citations" },
{ icon: <MenuBookIcon />, text: "Case studies & real-world examples" },
{ icon: <PsychologyIcon />, text: "Audience insights & pain points" },
];
const RESEARCH_MESSAGES = [
{ title: "Connecting to Research Engine", message: "Initializing neural search to gather fresh insights..." },
{ title: "Searching the Web", message: "Scanning thousands of sources for relevant data..." },
{ title: "Analyzing Content", message: "Extracting key facts, statistics, and trends..." },
{ title: "Verifying Information", message: "Cross-referencing sources to ensure accuracy..." },
{ title: "Synthesizing Insights", message: "Compiling findings into actionable research cards..." },
{ title: "Finalizing Research", message: "Organizing insights for your podcast episode..." },
];
interface QuerySelectionProps {
queries: Query[];
selectedQueries: Set<string>;
@@ -41,6 +63,7 @@ interface QuerySelectionProps {
onDeleteQuery: (id: string) => void;
analysis: any;
idea: string;
researchAnnouncement?: string;
}
export const QuerySelection: React.FC<QuerySelectionProps> = ({
@@ -56,7 +79,46 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
onDeleteQuery,
analysis,
idea,
researchAnnouncement,
}) => {
const [showResearchModal, setShowResearchModal] = useState(false);
const [researchStarted, setResearchStarted] = useState(false);
const [progressIndex, setProgressIndex] = useState(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const prevIsResearchingRef = useRef(isResearching);
// Close modal only when research actually completes (transitions from true to false)
useEffect(() => {
const wasResearching = prevIsResearchingRef.current;
const nowNotResearching = !isResearching;
if (showResearchModal && researchStarted && wasResearching && nowNotResearching) {
setTimeout(() => setShowResearchModal(false), 1000);
}
prevIsResearchingRef.current = isResearching;
}, [isResearching, showResearchModal, researchStarted]);
// Progress message cycling
useEffect(() => {
if (!isResearching || !researchStarted) {
setProgressIndex(0);
return;
}
const interval = setInterval(() => {
setProgressIndex((prev) => (prev < RESEARCH_MESSAGES.length - 1 ? prev + 1 : prev));
}, 4000);
return () => clearInterval(interval);
}, [isResearching, researchStarted]);
const handleStartResearch = () => {
setResearchStarted(true);
setProgressIndex(0);
onRunResearch();
};
const showProgressInModal = showResearchModal && (researchStarted || isResearching);
const [showRegenDialog, setShowRegenDialog] = useState(false);
const [regenFeedback, setRegenFeedback] = useState("");
const [isRegenerating, setIsRegenerating] = useState(false);
@@ -281,7 +343,7 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
</Typography>
)}
<PrimaryButton
onClick={onRunResearch}
onClick={() => setShowResearchModal(true)}
disabled={selectedCount === 0 || isResearching}
loading={isResearching}
startIcon={<SearchIcon />}
@@ -291,7 +353,7 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
}
>
{isResearching ? "Running Research..." : selectedCount === 0 ? "Next: Select Query" : "Run Research"}
{isResearching ? "Running Research..." : selectedCount === 0 ? "Next: Select Query" : "Start Neural Research"}
</PrimaryButton>
</Box>
</Stack>
@@ -357,8 +419,152 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
Generate New Queries
</PrimaryButton>
</DialogActions>
</Dialog>
{/* Research Progress Modal */}
<Dialog
open={showResearchModal}
onClose={() => !isResearching && setShowResearchModal(false)}
maxWidth="sm"
fullWidth
fullScreen={isMobile}
PaperProps={{
sx: {
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
border: "1px solid rgba(96, 165, 250, 0.3)",
borderRadius: 3,
},
}}
>
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1, fontSize: isMobile ? "1rem" : "1.25rem" }}>
{isResearching ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CircularProgress size={20} sx={{ color: "#60a5fa" }} />
Neural Research in Progress
</Box>
) : (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<SearchIcon sx={{ color: "#60a5fa" }} />
What You'll Get
</Box>
)}
</DialogTitle>
<DialogContent sx={{ color: "rgba(255,255,255,0.8)", minHeight: 200, py: 2, px: { xs: 2, sm: 3 }, maxHeight: { xs: "80vh", sm: "70vh" }, overflowY: "auto" }}>
{showProgressInModal ? (
<Stack spacing={2}>
<Box sx={{ textAlign: "center" }}>
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", mb: 2 }}>
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#60a5fa" }} />
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
<SearchIcon sx={{ color: "#60a5fa", fontSize: isMobile ? 20 : 24 }} />
</Box>
</Box>
<Typography variant="subtitle1" sx={{ color: "#60a5fa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
{RESEARCH_MESSAGES[Math.min(progressIndex, RESEARCH_MESSAGES.length - 1)].title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
{RESEARCH_MESSAGES[Math.min(progressIndex, RESEARCH_MESSAGES.length - 1)].message}
</Typography>
{researchAnnouncement && researchAnnouncement !== RESEARCH_MESSAGES[Math.min(progressIndex, RESEARCH_MESSAGES.length - 1)].message && (
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
{researchAnnouncement}
</Typography>
)}
<LinearProgress
sx={{
width: "100%",
height: 4,
borderRadius: 2,
bgcolor: "rgba(255,255,255,0.1)",
mt: 2,
"& .MuiLinearProgress-bar": { bgcolor: "#60a5fa", borderRadius: 2 },
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
Step {Math.min(progressIndex, RESEARCH_MESSAGES.length - 1) + 1} of {RESEARCH_MESSAGES.length}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
<Box>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Why Neural Research?
</Typography>
<Stack spacing={1}>
{[
"Fresh web data — bypasses LLM training cutoff",
"Reduces AI hallucinations with verified sources",
"Real-time trends and current statistics",
"Citation-backed facts for credibility",
].map((item, idx) => (
<Box key={idx} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleIcon sx={{ fontSize: 14, color: "#10b981" }} />
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.75rem" }}>
{item}
</Typography>
</Box>
))}
</Stack>
</Box>
</Stack>
) : (
<Stack spacing={2}>
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)", fontSize: isMobile ? "0.85rem" : "0.9rem" }}>
Click "Start Research" to gather AI-powered insights. Here's what we'll find for you:
</Typography>
<List>
{RESEARCH_FEATURES.map((feature, index) => (
<ListItem key={index} sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36, color: "#60a5fa" }}>{feature.icon}</ListItemIcon>
<ListItemText
primary={feature.text}
primaryTypographyProps={{ sx: { color: "rgba(255,255,255,0.9)", fontSize: isMobile ? "0.8rem" : "0.9rem" } }}
/>
</ListItem>
))}
</List>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(16, 185, 129, 0.1)", border: "1px solid rgba(16, 185, 129, 0.2)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<CheckCircleIcon sx={{ color: "#10b981", fontSize: 18, mt: 0.25 }} />
<Box>
<Typography variant="body2" sx={{ color: "#10b981", fontWeight: 600, fontSize: "0.85rem" }}>
Research Benefits
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.75rem", display: "block", mt: 0.5 }}>
Up-to-date information beyond LLM training data<br/>
Reduces fact-checking time significantly<br/>
Credible sources boost listener trust<br/>
Helps AI script sound expert and authoritative
</Typography>
</Box>
</Stack>
</Box>
</Stack>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
{showProgressInModal ? null : (
<>
<SecondaryButton onClick={() => setShowResearchModal(false)}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleStartResearch} startIcon={<SearchIcon />}>
Start Research
</PrimaryButton>
</>
)}
</DialogActions>
</Dialog>
</GlassyCard>
);
};
);
};

View File

@@ -0,0 +1,320 @@
import React from "react";
import {
Stack,
Typography,
CircularProgress,
LinearProgress,
Box,
Divider,
useMediaQuery,
useTheme,
} from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
CheckCircle as CheckCircleIcon,
Psychology as PsychologyIcon,
Insights as InsightsIcon,
Article as ArticleIcon,
Edit as EditIcon,
VolumeUp as VolumeUpIcon,
VideoLibrary as VideoLibraryIcon,
Lightbulb as LightbulbIcon,
Search as SearchIcon,
FactCheck as FactCheckIcon,
School as SchoolIcon,
Update as UpdateIcon,
Bolt as BoltIcon,
} from "@mui/icons-material";
const RESEARCH_MESSAGES = [
{ title: "Starting Research", message: "Preparing research queries and configuring search parameters..." },
{ title: "Searching Web", message: "Searching the web for relevant content, statistics, and latest developments..." },
{ title: "Analyzing Results", message: "Analyzing search results for key insights and factual information..." },
{ title: "Extracting Insights", message: "Extracting valuable insights, quotes, and data from verified sources..." },
{ title: "Validating Facts", message: "Cross-referencing information to ensure accuracy and credibility..." },
{ title: "Final Review", message: "Finalizing research data and preparing comprehensive summary..." },
];
const RESEARCH_BENEFITS = [
{
title: "Prevents AI Hallucinations",
description: "Research provides factual grounding so AI doesn't make up information",
icon: <BoltIcon />,
color: "#f59e0b",
},
{
title: "Latest Information",
description: "Gets up-to-date facts, statistics, and developments beyond AI's training date",
icon: <UpdateIcon />,
color: "#3b82f6",
},
{
title: "Credible Sources",
description: "Cites authoritative sources to build trust with your audience",
icon: <SchoolIcon />,
color: "#10b981",
},
];
const RESEARCH_STATS_CONFIG = [
{ label: "Queries", key: "searchQueries", icon: <SearchIcon />, color: "#a78bfa" },
{ label: "Sources", key: "sourceCount", icon: <ArticleIcon />, color: "#34d399", isNumber: true },
{ label: "Insights", key: "keyInsights", icon: <InsightsIcon />, color: "#f59e0b" },
{ label: "Facts", key: "factCards", icon: <FactCheckIcon />, color: "#60a5fa" },
];
const PODCAST_CREATION_JOURNEY = [
{
phase: "Analyze",
icon: <AutoAwesomeIcon />,
color: "#a78bfa",
description: "AI understands your topic and target audience",
benefit: "Identifies key themes and angles"
},
{
phase: "Research",
icon: <SearchIcon />,
color: "#60a5fa",
description: "Gathers facts, statistics, and latest insights",
benefit: "Evidence-based content"
},
{
phase: "Generate Script",
icon: <EditIcon />,
color: "#34d399",
description: "Transforms research into structured script",
benefit: "Factual, engaging content"
},
{
phase: "Final Render",
icon: <VideoLibraryIcon />,
color: "#ef4444",
description: "Your ready-to-publish podcast episode",
benefit: "Professional output"
},
];
interface ResearchProgressViewProps {
currentMessage?: string;
progressIndex: number;
searchQueries?: string[];
provider?: string;
searchType?: string;
}
export const ResearchProgressView: React.FC<ResearchProgressViewProps> = ({
currentMessage,
progressIndex,
searchQueries,
provider,
searchType,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const clampedIndex = Math.min(progressIndex, RESEARCH_MESSAGES.length - 1);
return (
<Stack spacing={2}>
{/* Current Status */}
<Box sx={{ textAlign: "center" }}>
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#60a5fa" }} />
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
<SearchIcon sx={{ color: "#60a5fa", fontSize: isMobile ? 20 : 24 }} />
</Box>
</Box>
<Typography variant="subtitle1" sx={{ color: "#60a5fa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
{RESEARCH_MESSAGES[clampedIndex].title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
{currentMessage || RESEARCH_MESSAGES[clampedIndex].message}
</Typography>
{currentMessage && (
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
{currentMessage}
</Typography>
)}
<LinearProgress
sx={{
width: "100%",
height: 4,
borderRadius: 2,
bgcolor: "rgba(255,255,255,0.1)",
mt: 2,
"& .MuiLinearProgress-bar": { bgcolor: "#60a5fa", borderRadius: 2 },
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
Step {clampedIndex + 1} of {RESEARCH_MESSAGES.length}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Why Research Matters */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Why Research Matters
</Typography>
<Stack spacing={1}>
{RESEARCH_BENEFITS.map((benefit, idx) => (
<Box key={idx} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 24,
height: 24,
borderRadius: "50%",
bgcolor: `${benefit.color}20`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
{React.cloneElement(benefit.icon, { sx: { color: benefit.color, fontSize: 14 } })}
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.75rem", display: "block" }}>
{benefit.title}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>
{benefit.description}
</Typography>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Search Info */}
{(provider || searchType || searchQueries) && (
<>
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Research Configuration
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{provider && (
<Box sx={{ flex: "1 1 auto", minWidth: 80, p: 1, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>Provider</Typography>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem", textTransform: "uppercase" }}>
{provider}
</Typography>
</Box>
)}
{searchType && (
<Box sx={{ flex: "1 1 auto", minWidth: 80, p: 1, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>Search Type</Typography>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem", textTransform: "capitalize" }}>
{searchType}
</Typography>
</Box>
)}
{searchQueries && (
<Box sx={{ flex: "1 1 auto", minWidth: 80, p: 1, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>Queries</Typography>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
{searchQueries.length}
</Typography>
</Box>
)}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
</>
)}
{/* Sequential Progress Steps */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Research Progress
</Typography>
<Stack spacing={0.5}>
{RESEARCH_MESSAGES.map((msg, idx) => {
const isCompleted = idx < clampedIndex;
const isCurrent = idx === clampedIndex;
return (
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 18,
height: 18,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#60a5fa" : "rgba(255,255,255,0.1)",
flexShrink: 0,
}}>
{isCompleted ? (
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
) : isCurrent ? (
<CircularProgress size={10} sx={{ color: "#fff" }} />
) : (
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#60a5fa" : "rgba(255,255,255,0.6)",
fontWeight: isCurrent ? 600 : 400,
fontSize: "0.75rem",
textDecoration: isCompleted ? "line-through" : "none",
}}>
{msg.title}
</Typography>
</Box>
</Stack>
);
})}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Journey Overview */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Your Podcast Journey
</Typography>
<Stack spacing={1}>
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
<Box key={idx} sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 28,
height: 28,
borderRadius: "50%",
bgcolor: `${phase.color}20`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
{React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
{phase.phase}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
{phase.description}
</Typography>
<Typography variant="caption" sx={{ color: phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
{phase.benefit}
</Typography>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Stack>
);
};

View File

@@ -47,6 +47,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
setRenderJobs,
initializeProject,
setBible,
setBackendProjectCreated,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
@@ -123,21 +124,55 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
let dbProject: any = null;
try {
dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
} catch (initError: any) {
const errorStr = initError?.message || "";
if (errorStr.includes("DUPLICATE_IDEA")) {
try {
const dupData = JSON.parse(errorStr);
const existingId = dupData.existing_project_id;
const existingIdea = dupData.existing_idea;
setAnnouncement("");
// Throw error to trigger UI modal
throw new Error(`DUPLICATE_IDEA:${existingId}:${existingIdea}`);
} catch (parseErr) {
console.error("Failed to parse duplicate idea error:", parseErr);
}
if (project) {
// Existing project - mark as DB-created (it was loaded from DB)
setBackendProjectCreated(true);
dbProject = null;
} else {
dbProject = await initializeProject(payload, projectId, avatarUrl);
}
} catch (initError: any) {
setBackendProjectCreated(false);
const errorStr = initError?.message || initError?.toString() || "";
if (errorStr.includes("DUPLICATE_IDEA") || errorStr.includes("existing_project_id") || errorStr.includes("409")) {
setAnnouncement("");
// Parse error message to extract existing project info
// Format: "DUPLICATE_IDEA:podcast_123:Some idea..." or the full error response
let existingId = "";
let existingIdea = "";
// Try extracting from "DUPLICATE_IDEA:projectid:idea" format
if (errorStr.includes("DUPLICATE_IDEA:")) {
const parts = errorStr.split("DUPLICATE_IDEA:")[1];
if (parts) {
const colonIdx = parts.indexOf(":");
if (colonIdx > 0) {
existingId = parts.substring(0, colonIdx).trim();
existingIdea = parts.substring(colonIdx + 1).trim();
}
}
}
// If still empty, try regex on full error response
if (!existingId) {
const idMatch = errorStr.match(/project_id["']?\s*[:=]\s*["']?([^"'$,\s]+)/);
existingId = idMatch ? idMatch[1].trim() : "";
}
if (!existingIdea) {
const ideaMatch = errorStr.match(/idea["']?\s*[:=]\s*["']([^"']+)["']/);
existingIdea = ideaMatch ? ideaMatch[1].trim() : "Similar project already exists";
}
console.error("[handleCreate] Duplicate project found:", existingId, existingIdea);
// Set the dialog info and show the dialog
setShowDuplicateDialog(true);
setDuplicateProjectInfo({
projectId: existingId || "unknown",
idea: existingIdea || "Similar project already exists"
});
return;
}
// Re-throw other errors to be handled by the outer catch
throw initError;
}
@@ -153,24 +188,37 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
}
// Update the project in database with the analysis results
try {
await podcastApi.updateProject(projectId, {
analysis: result.analysis,
estimate: result.estimate,
queries: result.queries,
selected_queries: [], // Don't auto-select - user must choose manually
avatar_url: result.avatar_url,
avatar_prompt: result.avatar_prompt,
});
} catch (error) {
console.error('Failed to update project with analysis results:', error);
// If dbProject exists, update it. Otherwise use localStorage fallback
if (dbProject) {
try {
await podcastApi.updateProject(projectId, {
analysis: result.analysis,
estimate: result.estimate,
queries: result.queries,
selected_queries: [],
avatar_url: result.avatar_url,
avatar_prompt: result.avatar_prompt,
});
setBackendProjectCreated(true);
console.log("[handleCreate] DB project created and updated successfully");
} catch (updateErr) {
console.warn("[handleCreate] updateProject failed, using localStorage fallback:", updateErr);
// Fall back to localStorage only
}
} else {
// DB not created (initializeProject failed or returned null) - use localStorage only
console.warn("[handleCreate] DB project not created - using localStorage only");
}
// Mark as created in local state so sync doesn't try to create later
setBackendProjectCreated(true);
setProject({
id: projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
podcastMode: payload.podcastMode,
avatarUrl: result.avatar_url || avatarUrl,
avatarPrompt: result.avatar_prompt || null,
avatarPersonaId: null,
@@ -184,8 +232,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
setBudgetCap(payload.budgetCap);
// Generate presenters AFTER analysis completes (to use analysis insights)
// This happens only if no avatar was uploaded
if (!avatarUrl && payload.speakers > 0 && result.analysis) {
// Only if no avatar was uploaded AND analysis didn't already generate one AND not audio_only
if (payload.podcastMode !== "audio_only" && !avatarUrl && !result.avatar_url && payload.speakers > 0 && result.analysis) {
try {
setAnnouncement("Generating presenter avatars using AI insights...");
const presentersResponse = await podcastApi.generatePresenters(
@@ -204,6 +252,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
podcastMode: payload.podcastMode,
avatarUrl: firstAvatar.avatar_url,
avatarPrompt: prompt,
avatarPersonaId: firstAvatar.persona_id || presentersResponse.persona_id || null,
@@ -216,7 +265,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
// Continue without presenters - can generate later
}
} else {
setAnnouncement("Analysis complete");
const audioOnlyNote = payload.podcastMode === "audio_only" ? " (audio-only mode)" : "";
setAnnouncement(`Analysis complete${audioOnlyNote}`);
}
} catch (error: any) {
// Handle duplicate idea error

View File

@@ -0,0 +1,247 @@
import React from "react";
import { Stack, Typography, Paper, Box, Button, CircularProgress, LinearProgress, Alert, alpha } from "@mui/material";
import {
VideoLibrary as VideoLibraryIcon,
Download as DownloadIcon,
CheckCircle as CheckCircleIcon,
} from "@mui/icons-material";
import { Script } from "../types";
interface RenderQueueFinalExportPanelProps {
script: Script;
allVideosReady: boolean;
finalVideoUrl: string | null;
finalVideoBlobUrl: string | null;
combiningVideos: boolean;
combiningProgress: { progress: number; message: string } | null;
onCombineFinalVideo: () => void;
}
export const RenderQueueFinalExportPanel: React.FC<RenderQueueFinalExportPanelProps> = ({
script,
allVideosReady,
finalVideoUrl,
finalVideoBlobUrl,
combiningVideos,
combiningProgress,
onCombineFinalVideo,
}) => {
if (!allVideosReady) return null;
return (
<Paper
elevation={3}
sx={{
mt: 4,
p: 4,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(6, 182, 212, 0.05) 100%)",
border: "2px solid",
borderColor: finalVideoUrl ? "success.main" : "info.light",
borderRadius: 3,
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "4px",
background: finalVideoUrl
? "linear-gradient(90deg, #10b981 0%, #06b6d4 100%)"
: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
},
}}
>
<Stack spacing={3}>
{/* Header */}
<Stack direction="row" alignItems="center" spacing={2}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
background: finalVideoUrl
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
}}
>
{finalVideoUrl ? (
<CheckCircleIcon sx={{ color: "white", fontSize: 32 }} />
) : (
<VideoLibraryIcon sx={{ color: "white", fontSize: 32 }} />
)}
</Box>
<Box>
<Typography
variant="h5"
sx={{
fontWeight: 700,
color: "#0f172a",
mb: 0.5,
}}
>
{finalVideoUrl ? "Final Podcast Ready!" : "Final Podcast Export"}
</Typography>
<Typography variant="body2" sx={{ color: "#64748b" }}>
{finalVideoUrl
? "Your complete podcast video is ready to download"
: `Combine ${script.scenes.length} scene videos into one final podcast`}
</Typography>
</Box>
</Stack>
{finalVideoUrl ? (
<Stack spacing={3}>
<Alert
severity="success"
icon={<CheckCircleIcon />}
sx={{
background: alpha("#10b981", 0.1),
border: "1px solid",
borderColor: alpha("#10b981", 0.3),
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Your final podcast video has been created successfully!
</Typography>
</Alert>
{/* Video Preview */}
<Box
sx={{
width: "100%",
maxWidth: 900,
mx: "auto",
borderRadius: 2,
overflow: "hidden",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
border: "1px solid",
borderColor: alpha("#10b981", 0.2),
}}
>
<video
controls
src={finalVideoBlobUrl || finalVideoUrl}
style={{
width: "100%",
display: "block",
backgroundColor: "#000",
}}
>
Your browser does not support video playback.
</video>
</Box>
{/* Download Button */}
<Stack direction="row" spacing={2} justifyContent="center" sx={{ pt: 2 }}>
<Button
variant="contained"
size="large"
startIcon={<DownloadIcon />}
onClick={() => {
if (finalVideoBlobUrl) {
const link = document.createElement("a");
link.href = finalVideoBlobUrl;
link.download = `podcast-final-${Date.now()}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
sx={{
px: 4,
py: 1.5,
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.4)",
"&:hover": {
background: "linear-gradient(135deg, #059669 0%, #047857 100%)",
boxShadow: "0 6px 16px rgba(16, 185, 129, 0.5)",
},
}}
>
Download Final Podcast
</Button>
</Stack>
</Stack>
) : (
<Stack spacing={3}>
<Alert
severity="info"
sx={{
background: alpha("#3b82f6", 0.08),
border: "1px solid",
borderColor: alpha("#3b82f6", 0.2),
}}
>
<Typography variant="body2">
<strong>Ready to export!</strong> Click below to combine all {script.scenes.length} scene videos into your final podcast video.
</Typography>
</Alert>
{combiningVideos && (
<Box sx={{ width: "100%" }}>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: "#0f172a" }}>
{combiningProgress?.message || "Combining videos..."}
</Typography>
{combiningProgress && (
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600 }}>
{combiningProgress.progress.toFixed(0)}%
</Typography>
)}
</Stack>
<LinearProgress
variant={combiningProgress ? "determinate" : "indeterminate"}
value={combiningProgress?.progress || 0}
sx={{
height: 8,
borderRadius: 4,
background: alpha("#667eea", 0.1),
"& .MuiLinearProgress-bar": {
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
borderRadius: 4,
},
}}
/>
{combiningProgress && combiningProgress.progress < 100 && (
<Typography variant="caption" sx={{ color: "#64748b", mt: 0.5, display: "block" }}>
Video encoding in progress. This may take a few minutes...
</Typography>
)}
</Box>
)}
<Button
variant="contained"
size="large"
fullWidth
startIcon={combiningVideos ? <CircularProgress size={20} sx={{ color: "white" }} /> : <VideoLibraryIcon />}
onClick={onCombineFinalVideo}
disabled={combiningVideos}
sx={{
py: 2,
fontSize: "1.1rem",
fontWeight: 700,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.4)",
"&:hover": {
background: "linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%)",
boxShadow: "0 6px 16px rgba(102, 126, 234, 0.5)",
},
"&:disabled": {
background: alpha("#667eea", 0.5),
},
}}
>
{combiningVideos ? "Combining Videos..." : "Combine Scenes into Final Video"}
</Button>
</Stack>
)}
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,142 @@
import React from "react";
import { Stack, Typography, Paper, Box, alpha } from "@mui/material";
import {
PlayArrow as PlayArrowIcon,
CheckCircle as CheckCircleIcon,
} from "@mui/icons-material";
import { Script, Job } from "../types";
interface RenderQueueStatusDashboardProps {
script: Script;
allVideosReady: boolean;
allScenesCompleted: boolean;
}
export const RenderQueueStatusDashboard: React.FC<RenderQueueStatusDashboardProps> = ({
script,
allVideosReady,
allScenesCompleted,
}) => {
return (
<Paper
elevation={0}
sx={{
mb: 3,
p: 2,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.02)",
}}
>
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap" useFlexGap>
{/* Status Chips */}
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{/* Scenes Count */}
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: alpha("#6366f1", 0.08),
color: "#4f46e5",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: alpha("#6366f1", 0.2),
}}
>
<Typography variant="caption" fontWeight={700} sx={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
Scenes
</Typography>
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.length}
</Typography>
</Box>
{/* Audio Status */}
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: script.scenes.every(s => s.audioUrl)
? alpha("#10b981", 0.1)
: alpha("#f59e0b", 0.1),
color: script.scenes.every(s => s.audioUrl) ? "#059669" : "#d97706",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: script.scenes.every(s => s.audioUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
}}
>
<Typography variant="caption" fontWeight={700}>
Audio
</Typography>
{script.scenes.every(s => s.audioUrl) ? (
<CheckCircleIcon sx={{ fontSize: 18 }} />
) : (
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.filter(s => s.audioUrl).length}/{script.scenes.length}
</Typography>
)}
</Box>
{/* Images Status */}
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: script.scenes.every(s => s.imageUrl)
? alpha("#10b981", 0.1)
: alpha("#f59e0b", 0.1),
color: script.scenes.every(s => s.imageUrl) ? "#059669" : "#d97706",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: script.scenes.every(s => s.imageUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
}}
>
<Typography variant="caption" fontWeight={700}>
Images
</Typography>
{script.scenes.every(s => s.imageUrl) ? (
<CheckCircleIcon sx={{ fontSize: 18 }} />
) : (
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.filter(s => s.imageUrl).length}/{script.scenes.length}
</Typography>
)}
</Box>
</Box>
{/* Dynamic Guidance Message */}
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 500, display: "flex", alignItems: "center", gap: 1 }}>
<Box component="span" sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: allVideosReady ? "#10b981" : "#3b82f6",
display: "inline-block"
}} />
{allVideosReady
? "All assets ready. You can combine videos below."
: !script.scenes.every(s => s.audioUrl)
? "Generate audio for all scenes to proceed."
: !script.scenes.every(s => s.imageUrl)
? "Generate images for video backgrounds."
: "Ready to generate scene videos."}
</Typography>
</Stack>
</Paper>
);
};

View File

@@ -66,15 +66,15 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const [showAudioModal, setShowAudioModal] = useState(false);
const [showImagePreview, setShowImagePreview] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: "Wise_Woman",
customVoiceId: undefined,
speed: 1.0,
voiceId: knobs.voice_id || "Wise_Woman",
customVoiceId: knobs.custom_voice_id || undefined,
speed: knobs.voice_speed ?? 1.0,
volume: 1.0,
pitch: 0.0,
emotion: scene.emotion || "neutral",
emotion: scene.emotion || knobs.voice_emotion || "neutral",
englishNormalization: true,
sampleRate: 24000,
bitrate: 64000,
sampleRate: knobs.sample_rate || 24000,
bitrate: knobs.bitrate === 'hd' ? 128000 : 64000,
channel: "1",
format: "mp3",
languageBoost: "auto",

View File

@@ -0,0 +1,50 @@
import React from "react";
import { Box, Typography, Stack, Chip } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
} from "@mui/icons-material";
import { Scene } from "../../types";
interface SceneEditorHeaderProps {
scene: Scene;
}
export const SceneEditorHeader: React.FC<SceneEditorHeaderProps> = ({ scene }) => {
return (
<>
<Typography
variant="h6"
sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1, color: "#0f172a", fontWeight: 600 }}
>
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
{scene.title}
</Typography>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
<Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"}
size="small"
color={scene.approved ? "success" : "warning"}
sx={{
background: scene.approved
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
color: scene.approved ? "#059669" : "#d97706",
border: scene.approved
? "1px solid rgba(16, 185, 129, 0.25)"
: "1px solid rgba(245, 158, 11, 0.25)",
fontWeight: 600,
fontSize: "0.75rem",
height: 26,
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
Duration: {scene.duration}s
</Typography>
</Stack>
</>
);
};

View File

@@ -0,0 +1,29 @@
import React from "react";
import { Box, Divider, Stack, Typography, CircularProgress } from "@mui/material";
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
interface SceneEditorMediaPanelProps {
hasAudio: boolean;
audioBlobUrl?: string | null;
isGenerating?: boolean;
}
// Minimal media panel wrapper extracted for refactor hygiene
export const SceneEditorMediaPanel: React.FC<SceneEditorMediaPanelProps> = ({ hasAudio, audioBlobUrl, isGenerating }) => {
return (
<Box sx={{ mt: 1, p: 2, borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)", background: "#fff" }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706" }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: hasAudio ? "#059669" : "#d97706" }}>
{hasAudio ? "Audio Generated" : "Loading Audio..."}
</Typography>
</Stack>
{audioBlobUrl ? (
<audio controls src={audioBlobUrl} style={{ width: "100%" }} />
) : isGenerating ? (
<CircularProgress size={20} />
) : null}
<Divider sx={{ mt: 2 }} />
</Box>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider, Chip, Tooltip } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon, Mic as MicIcon } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
@@ -300,6 +300,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
>
<EditNoteIcon sx={{ fontSize: "2rem" }} />
Script Editor
{knobs.voice_id && (() => {
const vid = knobs.voice_id;
const isCustom = Boolean(vid && !vid.startsWith("builtin:") && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(vid));
const vName = isCustom ? "My Voice Clone" : (vid === "Wise_Woman" ? "Wise Woman" : vid === "Friendly_Person" ? "Friendly Person" : vid === "Deep_Voice_Man" ? "Deep Voice Man" : vid?.replace(/_/g, " ") || "Default");
return (
<Chip
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
label={`Active Voice: ${vName}`}
size="small"
sx={{
ml: 2,
background: isCustom ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
color: isCustom ? "#10b981" : "#6366f1",
border: `1px solid ${isCustom ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
'& .MuiChip-icon': { color: isCustom ? "#10b981" : "#6366f1" },
fontWeight: 600,
fontSize: "0.75rem",
}}
/>
);
})()}
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
Review and refine your podcast script before rendering

View File

@@ -0,0 +1,550 @@
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { Script, Knobs, Scene, PodcastMode } from "../types";
import { podcastApi } from "../../../services/podcastApi";
interface ScriptEditorContextType {
// State
script: Script | null;
loading: boolean;
error: string | null;
podcastMode: PodcastMode;
approvingSceneId: string | null;
generatingAudioId: string | null;
showScriptFormatInfo: boolean;
combiningAudio: boolean;
scriptTab: "audio" | "video";
combinedAudioResult: { url: string; filename: string; duration: number; sceneCount: number } | null;
generatingBatchAudio: boolean;
batchAudioProgress: { completed: number; total: number } | null;
generatingChartId: string | null; // B-roll: generating chart preview
// Computed
activeScript: Script | null;
allApproved: boolean | null;
approvedCount: number;
totalScenes: number;
allScenesHaveAudio: boolean | null;
scenesWithAudio: number;
allScenesHaveAudioAndImages: boolean | null;
needsAudioGeneration: boolean | null;
scenesWithCharts: number; // B-roll: count of scenes with chart data
// Setters for UI state
setScript: React.Dispatch<React.SetStateAction<Script | null>>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<string | null>>;
setApprovingSceneId: React.Dispatch<React.SetStateAction<string | null>>;
setGeneratingAudioId: React.Dispatch<React.SetStateAction<string | null>>;
setShowScriptFormatInfo: React.Dispatch<React.SetStateAction<boolean>>;
setCombiningAudio: React.Dispatch<React.SetStateAction<boolean>>;
setScriptTab: React.Dispatch<React.SetStateAction<"audio" | "video">>;
setCombinedAudioResult: React.Dispatch<React.SetStateAction<{ url: string; filename: string; duration: number; sceneCount: number } | null>>;
setGeneratingBatchAudio: React.Dispatch<React.SetStateAction<boolean>>;
setBatchAudioProgress: React.Dispatch<React.SetStateAction<{ completed: number; total: number } | null>>;
setGeneratingChartId: React.Dispatch<React.SetStateAction<string | null>>;
// Actions
updateScene: (updated: Scene) => void;
approveScene: (sceneId: string) => Promise<void>;
deleteScene: (sceneId: string) => void;
generateAllAudio: () => Promise<void>;
combineAudio: () => Promise<void>;
emitScriptChange: (next: Script) => void;
// B-roll actions
generateChartPreviews: () => Promise<void>;
regenerateChart: (sceneId: string) => Promise<void>;
removeChart: (sceneId: string) => void;
}
const ScriptEditorContext = createContext<ScriptEditorContextType | undefined>(undefined);
interface ScriptEditorProviderProps {
children: ReactNode;
projectId: string;
idea: string;
rawResearch: any;
knobs: Knobs;
speakers: number;
durationMinutes: number;
initialScript: Script | null;
initialAudioScript?: Script | null;
initialVideoScript?: Script | null;
podcastMode?: PodcastMode;
analysis?: any;
outline?: any;
onScriptChange: (script: Script) => void;
onError: (message: string) => void;
}
export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
children,
projectId,
idea,
rawResearch,
knobs,
speakers,
durationMinutes,
initialScript,
initialAudioScript,
initialVideoScript,
podcastMode = "video_only",
analysis,
outline,
onScriptChange,
onError,
}) => {
// Core state
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// UI state
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
const [combiningAudio, setCombiningAudio] = useState(false);
const [scriptTab, setScriptTab] = useState<"audio" | "video">("video");
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
filename: string;
duration: number;
sceneCount: number;
} | null>(null);
const [generatingBatchAudio, setGeneratingBatchAudio] = useState(false);
const [batchAudioProgress, setBatchAudioProgress] = useState<{ completed: number; total: number } | null>(null);
const [generatingChartId, setGeneratingChartId] = useState<string | null>(null);
// Emit script changes to parent (deferred to avoid setState during render)
const emitScriptChange = useCallback(
(next: Script) => {
Promise.resolve().then(() => onScriptChange(next));
},
[onScriptChange]
);
// Determine which script to display based on mode and tab
const getActiveScript = (): Script | null => {
const currentScript = script || null;
if (podcastMode === "audio_only") {
if (currentScript?.audioScript) {
return { scenes: currentScript.audioScript };
}
return initialAudioScript || null;
}
if (podcastMode === "video_only") {
return currentScript || initialVideoScript || null;
}
if (podcastMode === "audio_video") {
if (scriptTab === "audio") {
if (currentScript?.audioScript) {
return { scenes: currentScript.audioScript };
}
return initialAudioScript || null;
} else {
if (currentScript?.videoScript) {
return { scenes: currentScript.videoScript };
}
return currentScript || initialVideoScript || null;
}
}
return currentScript || initialVideoScript || null;
};
const activeScript = getActiveScript();
// Computed values
const allApproved = activeScript && activeScript.scenes.every((s) => s.approved);
const approvedCount = activeScript ? activeScript.scenes.filter((s) => s.approved).length : 0;
const totalScenes = activeScript ? activeScript.scenes.length : 0;
const allScenesHaveAudio = activeScript && activeScript.scenes.every((s) => s.audioUrl);
const scenesWithAudio = activeScript ? activeScript.scenes.filter((s) => s.audioUrl).length : 0;
const allScenesHaveAudioAndImages = activeScript && (
podcastMode === "audio_only"
? activeScript.scenes.every((s) => s.audioUrl)
: activeScript.scenes.every((s) => s.audioUrl && s.imageUrl)
);
const needsAudioGeneration = activeScript && !allScenesHaveAudio && activeScript.scenes.some((s) => !s.audioUrl);
// B-roll computed
const scenesWithCharts = activeScript ? activeScript.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length : 0;
// Sync with parent state
useEffect(() => {
if (initialScript) {
setScript(initialScript);
}
}, [initialScript]);
// Generate script effect - only if not already generated by parent
// This prevents duplicate API calls when both parent workflow and this component try to generate
useEffect(() => {
// Skip if parent already provided script via props
if (script || initialScript) {
return;
}
if (!rawResearch) {
return;
}
// Skip if podcastMode is audio_only (script should be passed from parent for audio_only)
// Parent workflow already generates the script, we just display it here
if (podcastMode === "audio_only") {
return;
}
let mounted = true;
setLoading(true);
setError(null);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
analysis,
outline,
})
.then((res) => {
if (mounted) {
setScript(res);
emitScriptChange(res);
setError(null);
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to generate script";
setError(message);
onError(message);
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, analysis, outline, emitScriptChange, onError, script]);
const updateScene = (updated: Scene) => {
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScript = {
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
};
emitScriptChange(updatedScript);
return updatedScript;
});
};
const approveScene = async (sceneId: string) => {
try {
setApprovingSceneId(sceneId);
await podcastApi.approveScene({ projectId, sceneId });
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScript = {
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
};
emitScriptChange(updatedScript);
return updatedScript;
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve scene";
setError(message);
onError(message);
throw err;
} finally {
setApprovingSceneId((current) => (current === sceneId ? null : current));
}
};
const deleteScene = useCallback((sceneId: string) => {
if (!activeScript) return;
if (activeScript.scenes.length <= 1) {
onError("Cannot delete the last scene. At least one scene is required.");
return;
}
const sceneToDelete = activeScript.scenes.find(s => s.id === sceneId);
if (!sceneToDelete) return;
const confirmDelete = window.confirm(
`Are you sure you want to delete "${sceneToDelete.title}"? This action cannot be undone.`
);
if (!confirmDelete) return;
const updatedScenes = activeScript.scenes.filter(s => s.id !== sceneId);
const updatedScript = { ...activeScript, scenes: updatedScenes };
emitScriptChange(updatedScript);
setScript(updatedScript);
}, [activeScript, emitScriptChange, onError]);
const generateAllAudio = useCallback(async () => {
if (!activeScript || !projectId || !knobs) return;
const scenesNeedingAudio = activeScript.scenes.filter((s) => !s.audioUrl);
if (scenesNeedingAudio.length === 0) {
onError("All scenes already have audio generated.");
return;
}
try {
setGeneratingBatchAudio(true);
setBatchAudioProgress({ completed: 0, total: scenesNeedingAudio.length });
const sceneData = scenesNeedingAudio.map((scene) => ({
id: scene.id,
title: scene.title,
lines: scene.lines.map((line) => ({ text: line.text })),
}));
const result = await podcastApi.generateBatchAudio({
scenes: sceneData,
voiceId: knobs.voice_id,
customVoiceId: knobs.custom_voice_id,
speed: knobs.voice_speed,
emotion: knobs.voice_emotion,
englishNormalization: true,
});
const updatedScenes = activeScript.scenes.map((scene) => {
const batchResult = result.results.find((r: any) => r.sceneId === scene.id);
if (batchResult) {
return { ...scene, audioUrl: batchResult.audioUrl };
}
return scene;
});
await emitScriptChange({ ...activeScript, scenes: updatedScenes });
setBatchAudioProgress({ completed: scenesNeedingAudio.length, total: scenesNeedingAudio.length });
} catch (error: any) {
console.error("Batch audio generation failed:", error);
onError(`Failed to generate audio: ${error.message || error}`);
} finally {
setGeneratingBatchAudio(false);
setBatchAudioProgress(null);
}
}, [activeScript, projectId, knobs, emitScriptChange, onError]);
const combineAudio = useCallback(async () => {
if (!activeScript || !projectId) return;
try {
setCombiningAudio(true);
const sceneIds: string[] = [];
const sceneAudioUrls: string[] = [];
activeScript.scenes.forEach((scene) => {
if (scene.audioUrl) {
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
if (audioUrl) {
sceneIds.push(scene.id);
sceneAudioUrls.push(audioUrl);
}
}
});
if (sceneIds.length === 0) {
onError("No audio files found to combine.");
return;
}
const result = await podcastApi.combineAudio({
projectId,
sceneIds,
sceneAudioUrls,
});
setCombinedAudioResult({
url: result.combined_audio_url,
filename: result.combined_audio_filename,
duration: result.total_duration,
sceneCount: result.scene_count,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to combine audio";
onError(`Failed to combine audio: ${message}`);
} finally {
setCombiningAudio(false);
}
}, [activeScript, projectId, onError]);
// =====================
// B-Roll Actions
// =====================
const generateChartPreviews = useCallback(async () => {
if (!activeScript) return;
const scenesWithData = activeScript.scenes.filter(
(s) => s.chart_data && Object.keys(s.chart_data).length > 0
);
if (scenesWithData.length === 0) {
onError("No scenes have chart data to generate previews.");
return;
}
try {
setGeneratingChartId("all");
const updatedScenes = await Promise.all(
activeScript.scenes.map(async (scene) => {
if (!scene.chart_data || Object.keys(scene.chart_data).length === 0) {
return scene;
}
try {
const result = await podcastApi.generateChartPreview({
chart_data: scene.chart_data,
chart_type: scene.chart_data.type || "bar_comparison",
title: scene.title,
});
return {
...scene,
broll_preview_url: result.preview_url,
chart_id: result.chart_id,
};
} catch (err) {
console.error(`Failed to generate chart for scene ${scene.id}:`, err);
return scene;
}
})
);
const updatedScript = { ...activeScript, scenes: updatedScenes };
setScript(updatedScript);
emitScriptChange(updatedScript);
} catch (error: any) {
console.error("Chart preview generation failed:", error);
onError(`Failed to generate chart previews: ${error.message || error}`);
} finally {
setGeneratingChartId(null);
}
}, [activeScript, emitScriptChange, onError]);
const regenerateChart = useCallback(async (sceneId: string) => {
if (!activeScript) return;
const scene = activeScript.scenes.find((s) => s.id === sceneId);
if (!scene || !scene.chart_data) return;
try {
setGeneratingChartId(sceneId);
const result = await podcastApi.generateChartPreview({
chart_data: scene.chart_data,
chart_type: scene.chart_data.type || "bar_comparison",
title: scene.title,
});
const updatedScenes = activeScript.scenes.map((s) =>
s.id === sceneId
? { ...s, broll_preview_url: result.preview_url, chart_id: result.chart_id }
: s
);
const updatedScript = { ...activeScript, scenes: updatedScenes };
setScript(updatedScript);
emitScriptChange(updatedScript);
} catch (error: any) {
console.error("Chart regeneration failed:", error);
onError(`Failed to regenerate chart: ${error.message || error}`);
} finally {
setGeneratingChartId(null);
}
}, [activeScript, emitScriptChange, onError]);
const removeChart = useCallback((sceneId: string) => {
if (!activeScript) return;
const updatedScenes = activeScript.scenes.map((s) =>
s.id === sceneId
? { ...s, chart_data: undefined, broll_preview_url: undefined, broll_video_url: undefined }
: s
);
const updatedScript = { ...activeScript, scenes: updatedScenes };
setScript(updatedScript);
emitScriptChange(updatedScript);
}, [activeScript, emitScriptChange]);
const value: ScriptEditorContextType = {
// State
script,
loading,
error,
podcastMode,
approvingSceneId,
generatingAudioId,
showScriptFormatInfo,
combiningAudio,
scriptTab,
combinedAudioResult,
generatingBatchAudio,
batchAudioProgress,
generatingChartId,
// Computed
activeScript,
allApproved,
approvedCount,
totalScenes,
allScenesHaveAudio,
scenesWithAudio,
allScenesHaveAudioAndImages,
needsAudioGeneration,
scenesWithCharts,
// Setters for UI state
setScript,
setLoading,
setError,
setApprovingSceneId,
setGeneratingAudioId,
setShowScriptFormatInfo,
setCombiningAudio,
setScriptTab,
setCombinedAudioResult,
setGeneratingBatchAudio,
setBatchAudioProgress,
setGeneratingChartId,
// Actions
updateScene,
approveScene,
deleteScene,
generateAllAudio,
combineAudio,
emitScriptChange,
// B-roll actions
generateChartPreviews,
regenerateChart,
removeChart,
};
return (
<ScriptEditorContext.Provider value={value}>
{children}
</ScriptEditorContext.Provider>
);
};
export const useScriptEditor = (): ScriptEditorContextType => {
const context = useContext(ScriptEditorContext);
if (!context) {
throw new Error("useScriptEditor must be used within ScriptEditorProvider");
}
return context;
};

View File

@@ -0,0 +1,109 @@
import React from "react";
import { Box, Stack, Typography, Tabs, Tab, Chip } from "@mui/material";
import { EditNote as EditNoteIcon, ArrowBack as ArrowBackIcon, AudioFile as AudioFileIcon, Videocam as VideocamIcon, Mic as MicIcon } from "@mui/icons-material";
import { PodcastMode, Knobs } from "../types";
import { SecondaryButton } from "../ui";
import { useScriptEditor } from "./ScriptEditorContext";
interface ScriptEditorLayoutProps {
onBackToResearch: () => void;
knobs?: Knobs;
}
// Helper function to get voice display name
const getVoiceDisplayName = (voiceId?: string): string => {
if (!voiceId) return "Default";
if (voiceId === "Wise_Woman") return "Wise Woman";
if (voiceId === "Friendly_Person") return "Friendly Person";
if (voiceId === "Deep_Voice_Man") return "Deep Voice Man";
if (voiceId === "Calm_Woman") return "Calm Woman";
return voiceId.replace(/_/g, " ");
};
export const ScriptEditorLayout: React.FC<ScriptEditorLayoutProps> = ({ onBackToResearch, knobs }) => {
const { podcastMode, scriptTab, setScriptTab } = useScriptEditor();
const showTabs = podcastMode === "audio_video";
const voiceId = knobs?.voice_id;
const isCustomVoice = Boolean(voiceId && !voiceId.startsWith("builtin:") &&
!["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman",
"Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man",
"Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess",
"Sweet_Girl_2", "Exuberant_Girl"].includes(voiceId));
const voiceName = isCustomVoice ? "My Voice Clone" : getVoiceDisplayName(voiceId);
return (
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Box sx={{ flex: 1 }}>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 700,
letterSpacing: "-0.02em",
display: "flex",
alignItems: "center",
gap: 1.5,
fontSize: { xs: "1.75rem", md: "2rem" },
}}
>
<EditNoteIcon sx={{ fontSize: "2rem" }} />
Script Editor
{voiceId && (
<Chip
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
label={`Active Voice: ${voiceName}`}
size="small"
sx={{
ml: 2,
background: isCustomVoice ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
color: isCustomVoice ? "#10b981" : "#6366f1",
border: `1px solid ${isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
'& .MuiChip-icon': { color: isCustomVoice ? "#10b981" : "#6366f1" },
fontWeight: 600,
fontSize: "0.75rem",
}}
/>
)}
{showTabs && (
<Tabs
value={scriptTab}
onChange={(_, v) => setScriptTab(v)}
sx={{
ml: 3,
minHeight: 32,
'& .MuiTab-root': {
minHeight: 32,
py: 0.5,
px: 2,
fontSize: '0.8rem',
}
}}
>
<Tab
value="audio"
label="Audio Script"
icon={<AudioFileIcon sx={{ fontSize: '1rem' }} />}
iconPosition="start"
/>
<Tab
value="video"
label="Video Script"
icon={<VideocamIcon sx={{ fontSize: '1rem' }} />}
iconPosition="start"
/>
</Tabs>
)}
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
Review and refine your podcast script before rendering
</Typography>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,270 @@
import React from "react";
import {
Stack,
Typography,
CircularProgress,
LinearProgress,
Box,
Divider,
useMediaQuery,
useTheme,
} from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
CheckCircle as CheckCircleIcon,
Psychology as PsychologyIcon,
Insights as InsightsIcon,
Article as ArticleIcon,
Edit as EditIcon,
VolumeUp as VolumeUpIcon,
VideoLibrary as VideoLibraryIcon,
Lightbulb as LightbulbIcon,
Search as SearchIcon,
FactCheck as FactCheckIcon,
} from "@mui/icons-material";
import { Research } from "../types";
const SCRIPT_GENERATION_MESSAGES = [
{ title: "Processing Research", message: "Extracting key insights, statistics, and quotes from your research data..." },
{ title: "Analyzing Your Topic", message: "Using your topic to shape the episode narrative and content structure..." },
{ title: "Structuring Scenes", message: "Creating scene-by-scene breakdown based on research findings..." },
{ title: "Writing Dialogue", message: "Generating natural conversation that flows from your insights..." },
{ title: "Adding Transitions", message: "Creating smooth flow between scenes and topics..." },
{ title: "Optimizing Pacing", message: "Ensuring engaging rhythm throughout the episode..." },
{ title: "Final Review", message: "Validating script quality and preparing for editing..." },
];
const RESEARCH_STATS_CONFIG = [
{ label: "Key Insights", key: "keyInsights", icon: <InsightsIcon />, color: "#a78bfa" },
{ label: "Fact Cards", key: "factCards", icon: <FactCheckIcon />, color: "#34d399" },
{ label: "Angles", key: "mappedAngles", icon: <LightbulbIcon />, color: "#f59e0b" },
{ label: "Sources", key: "sourceCount", icon: <SearchIcon />, color: "#60a5fa", isNumber: true },
];
const PODCAST_CREATION_JOURNEY = [
{
phase: "Generate Script",
icon: <AutoAwesomeIcon />,
color: "#a78bfa",
description: "AI transforms research into a structured podcast script",
benefit: "Professional script based on your research insights"
},
{
phase: "Edit Scenes",
icon: <EditIcon />,
color: "#34d399",
description: "Review and refine each scene in the Script Editor",
benefit: "Full control over your content"
},
{
phase: "Approve Content",
icon: <CheckCircleIcon />,
color: "#10b981",
description: "Mark scenes as approved before audio generation",
benefit: "Ensures content isexactly as you want"
},
{
phase: "Generate Audio",
icon: <VolumeUpIcon />,
color: "#f59e0b",
description: "Convert script to natural-sounding podcast audio",
benefit: "Ready-to-use audio narration"
},
{
phase: "Final Render",
icon: <VideoLibraryIcon />,
color: "#ef4444",
description: "Combine into your final podcast episode",
benefit: "Download or share your episode"
},
];
interface ScriptProgressViewProps {
currentMessage?: string;
progressIndex: number;
research?: Research | null;
idea?: string;
}
export const ScriptProgressView: React.FC<ScriptProgressViewProps> = ({
currentMessage,
progressIndex,
research,
idea,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const clampedIndex = Math.min(progressIndex, SCRIPT_GENERATION_MESSAGES.length - 1);
const getResearchValue = (key: string, isNumber?: boolean) => {
if (!research) return 0;
const value = (research as any)[key];
if (isNumber) return research.sourceCount || 0;
return Array.isArray(value) ? value.length : value || 0;
};
return (
<Stack spacing={2}>
{/* Current Status */}
<Box sx={{ textAlign: "center" }}>
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#a78bfa" }} />
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: isMobile ? 20 : 24 }} />
</Box>
</Box>
<Typography variant="subtitle1" sx={{ color: "#a78bfa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
{SCRIPT_GENERATION_MESSAGES[clampedIndex].title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
{currentMessage || SCRIPT_GENERATION_MESSAGES[clampedIndex].message}
</Typography>
{currentMessage && (
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
{currentMessage}
</Typography>
)}
<LinearProgress
sx={{
width: "100%",
height: 4,
borderRadius: 2,
bgcolor: "rgba(255,255,255,0.1)",
mt: 2,
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 2 },
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
Step {clampedIndex + 1} of {SCRIPT_GENERATION_MESSAGES.length}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Research Stats */}
{research && (
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Using Your Research
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{RESEARCH_STATS_CONFIG.map((stat, idx) => {
const value = getResearchValue(stat.key, stat.isNumber);
return (
<Box key={idx} sx={{
flex: "1 1 auto",
minWidth: 80,
p: 1,
borderRadius: 1.5,
bgcolor: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
textAlign: "center",
}}>
<Box sx={{ color: stat.color, mb: 0.25 }}>{stat.icon}</Box>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 700, fontSize: "1rem" }}>
{value}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem" }}>
{stat.label}
</Typography>
</Box>
);
})}
</Stack>
</Box>
)}
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Sequential Progress Steps */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Script Generation Progress
</Typography>
<Stack spacing={0.5}>
{SCRIPT_GENERATION_MESSAGES.map((msg, idx) => {
const isCompleted = idx < clampedIndex;
const isCurrent = idx === clampedIndex;
return (
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 18,
height: 18,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.1)",
flexShrink: 0,
}}>
{isCompleted ? (
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
) : isCurrent ? (
<CircularProgress size={10} sx={{ color: "#fff" }} />
) : (
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.6)",
fontWeight: isCurrent ? 600 : 400,
fontSize: "0.75rem",
textDecoration: isCompleted ? "line-through" : "none",
}}>
{msg.title}
</Typography>
</Box>
</Stack>
);
})}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Journey Overview */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Your Podcast Journey
</Typography>
<Stack spacing={1}>
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
<Box key={idx} sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 28,
height: 28,
borderRadius: "50%",
bgcolor: `${phase.color}20`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
{React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
{phase.phase}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
{phase.description}
</Typography>
<Typography variant="caption" sx={{ color: phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
{phase.benefit}
</Typography>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,140 @@
import React from "react";
import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
export const BrollInfoPanel: React.FC = () => {
const {
activeScript,
generatingChartId,
setGeneratingChartId,
generateChartPreviews,
regenerateChart,
removeChart,
scenesWithCharts
} = useScriptEditor();
if (!activeScript || activeScript.scenes.length === 0) {
return null;
}
const scenesWithData = activeScript.scenes.filter(s => s.chart_data && Object.keys(s.chart_data).length > 0);
const hasChartData = scenesWithData.length > 0;
return (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)",
border: "1px solid rgba(34, 197, 94, 0.15)",
borderRadius: 2,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<BarChartIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
B-Roll Charts
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Programmatic charts extracted from research data
</Typography>
</Box>
{hasChartData && (
<Chip
label={`${scenesWithData.length} scene${scenesWithData.length > 1 ? 's' : ''} with charts`}
size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a", fontWeight: 600 }}
/>
)}
</Stack>
{!hasChartData ? (
<Alert severity="info" sx={{ background: "rgba(34, 197, 94, 0.06)", border: "1px solid rgba(34, 197, 94, 0.15)", "& .MuiAlert-icon": { color: "#22c55e" } }}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<strong style={{ fontWeight: 600 }}>No charts detected.</strong> If your research contains statistics or metrics, the script generation will automatically extract chart data for B-roll visualization.
</Typography>
</Alert>
) : (
<Stack spacing={2}>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Your script contains <strong style={{ fontWeight: 600 }}>{scenesWithData.length}</strong> scene(s) with chart data.
Click below to generate chart previews for the Write phase.
</Typography>
<Stack direction="row" spacing={2}>
<Button
variant="contained"
startIcon={generatingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={generateChartPreviews}
disabled={!!generatingChartId}
sx={{
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)",
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" },
textTransform: "none",
fontWeight: 600,
}}
>
{generatingChartId ? "Generating..." : "Generate Chart Previews"}
</Button>
</Stack>
{scenesWithData.map((scene) => (
<Box
key={scene.id}
sx={{
p: 2,
background: "rgba(0,0,0,0.02)",
borderRadius: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{scene.title}
</Typography>
<Typography variant="caption" sx={{ color: "#64748b" }}>
{scene.chart_data?.type || "chart"} {scene.chart_data?.labels?.length || 0} data points
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{generatingChartId === scene.id ? (
<CircularProgress size={20} />
) : scene.broll_preview_url ? (
<>
<Chip
label="Preview Ready"
size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }}
/>
<Button
size="small"
startIcon={<RefreshIcon />}
onClick={() => regenerateChart(scene.id)}
>
Regenerate
</Button>
<Button
size="small"
startIcon={<DeleteIcon />}
onClick={() => removeChart(scene.id)}
sx={{ color: "#ef4444" }}
>
Remove
</Button>
</>
) : null}
</Stack>
</Box>
))}
</Stack>
)}
</Paper>
);
};

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
import { CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { Script } from "../../types";
import { useScriptEditor } from "../ScriptEditorContext";
import { PrimaryButton } from "../../ui";
interface ScriptEditorApprovalPanelProps {
onProceedToRendering: (script: Script) => void;
}
export const ScriptEditorApprovalPanel: React.FC<ScriptEditorApprovalPanelProps> = ({ onProceedToRendering }) => {
const { activeScript, allApproved, approvedCount, totalScenes, allScenesHaveAudioAndImages } = useScriptEditor();
const approved = allApproved ?? false;
const ready = allScenesHaveAudioAndImages ?? false;
return (
<Paper sx={{ p: 3.5, background: approved ? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)" : "#ffffff", border: approved ? "2px solid rgba(16, 185, 129, 0.25)" : "1px solid rgba(15, 23, 42, 0.08)", borderRadius: 3 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
<CheckCircleIcon fontSize="small" sx={{ color: approved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />Approval Status
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
{approvedCount} of {totalScenes} scenes approved{!approved && " — Approve all scenes first"}
</Typography>
{!ready && <LinearProgress variant="determinate" value={ready ? 100 : (activeScript ? (activeScript.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100 : 0)} sx={{ mt: 1, height: 6, borderRadius: 3 }} />}
</Box>
<PrimaryButton onClick={() => activeScript && onProceedToRendering(activeScript)} disabled={!ready} startIcon={<PlayArrowIcon />}>
Proceed to Rendering
</PrimaryButton>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,53 @@
import React from "react";
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
import { AudioFile as AudioFileIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
import { PrimaryButton } from "../../ui";
export const ScriptEditorAudioPanel: React.FC = () => {
const { activeScript, needsAudioGeneration, generatingBatchAudio, batchAudioProgress, generateAllAudio } = useScriptEditor();
if (!(needsAudioGeneration ?? false)) {
return null;
}
return (
<Paper
sx={{
p: 2,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
border: "1px solid rgba(16, 185, 129, 0.2)",
borderRadius: 2,
}}
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle1" sx={{ color: "#059669", fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<AudioFileIcon /> Generate All Audio
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5 }}>
{activeScript && `${activeScript.scenes.filter(s => !s.audioUrl).length} scenes need audio`}
</Typography>
</Box>
<PrimaryButton
onClick={generateAllAudio}
disabled={generatingBatchAudio}
loading={generatingBatchAudio}
startIcon={<AudioFileIcon />}
sx={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
{generatingBatchAudio
? (batchAudioProgress ? `Generating ${batchAudioProgress.completed}/${batchAudioProgress.total}...` : "Generating...")
: "Generate All Audio"}
</PrimaryButton>
</Stack>
{(batchAudioProgress !== null && batchAudioProgress !== undefined) && (
<LinearProgress
variant="determinate"
value={(batchAudioProgress.completed / batchAudioProgress.total) * 100}
sx={{ mt: 2, height: 8, borderRadius: 4 }}
/>
)}
</Paper>
);
};

View File

@@ -0,0 +1,70 @@
import React from "react";
import { Stack, Typography, Paper, Alert, alpha } from "@mui/material";
import { Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
import { PrimaryButton, SecondaryButton } from "../../ui";
import { InlineAudioPlayer } from "../../InlineAudioPlayer";
import { aiApiClient } from "../../../../api/client";
interface ScriptEditorDownloadPanelProps {
projectId: string;
}
export const ScriptEditorDownloadPanel: React.FC<ScriptEditorDownloadPanelProps> = ({ projectId }) => {
const { allScenesHaveAudio, scenesWithAudio, combiningAudio, combinedAudioResult, combineAudio, setCombinedAudioResult } = useScriptEditor();
if (!(allScenesHaveAudio ?? false)) {
return null;
}
const handleDownloadAgain = async () => {
if (!combinedAudioResult) return;
try {
let audioPath = combinedAudioResult.url.startsWith('/') ? combinedAudioResult.url : `/${combinedAudioResult.url}`;
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
audioPath = `/api/podcast/audio/${filename}`;
}
audioPath = audioPath.split('?')[0];
const response = await aiApiClient.get(audioPath, { responseType: 'blob' });
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = combinedAudioResult.filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
} catch (error) {
console.error('Failed to download audio:', error);
}
};
return (
<Paper sx={{ p: 3, background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)", border: "1px solid rgba(102, 126, 234, 0.15)", borderRadius: 2 }}>
<Stack spacing={3}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600 }}>Download Audio-Only Podcast</Typography>
{!combinedAudioResult ? (
<>
<PrimaryButton onClick={combineAudio} disabled={combiningAudio} loading={combiningAudio} startIcon={<DownloadIcon />} sx={{ minWidth: 280, fontSize: "1rem", py: 1.5, background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" }}>
{combiningAudio ? "Combining Audio..." : "Download Audio-Only Podcast"}
</PrimaryButton>
<Typography variant="caption" sx={{ color: "#64748b", fontStyle: "italic" }}>This will combine all {scenesWithAudio} scene audio files into one complete podcast episode.</Typography>
</>
) : (
<Stack spacing={2}>
<Alert severity="success" sx={{ background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)", "& .MuiAlert-icon": { color: "#10b981" } }}>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}> Combined audio generated successfully! ({combinedAudioResult.sceneCount} scenes, {Math.round(combinedAudioResult.duration)}s)</Typography>
</Alert>
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
<Stack direction="row" spacing={2}>
<SecondaryButton onClick={handleDownloadAgain} startIcon={<DownloadIcon />}>Download Again</SecondaryButton>
<SecondaryButton onClick={() => { setCombinedAudioResult(null); combineAudio(); }} disabled={combiningAudio} loading={combiningAudio} startIcon={<RefreshIcon />}>Regenerate</SecondaryButton>
</Stack>
</Stack>
)}
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,87 @@
import React from "react";
import { Stack, Box, Typography, Alert, Paper } from "@mui/material";
import { Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
interface FormatItem {
num: string;
title: string;
desc: string;
}
const formatItems: FormatItem[] = [
{ num: "1", title: "Natural Pauses & Rhythm", desc: "The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns and conversation flow." },
{ num: "2", title: "Emphasis Markers", desc: "Lines marked with emphasis help highlight important points, statistics, or key insights." },
{ num: "3", title: "Short, Conversational Sentences", desc: "The script uses shorter sentences written in a conversational style that matches how people actually speak." },
{ num: "4", title: "Scene-Specific Emotions", desc: "Each scene has an emotional tone that guides the AI voice's delivery." },
{ num: "5", title: "Optimized for Podcast Narration", desc: "The script is optimized with slightly slower pacing and natural pronunciation settings." },
];
export const ScriptEditorInfoPanel: React.FC = () => {
const { showScriptFormatInfo, setShowScriptFormatInfo } = useScriptEditor();
return (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
border: "1px solid rgba(99, 102, 241, 0.15)",
borderRadius: 2,
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
Why This Script Format?
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Understanding how your script creates natural, human-like audio
</Typography>
</Box>
</Stack>
<Box
sx={{
color: "#6366f1",
cursor: "pointer",
p: 0.5,
borderRadius: 1,
"&:hover": { background: "rgba(99, 102, 241, 0.1)" },
}}
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
>
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Stack>
{showScriptFormatInfo && (
<Stack spacing={2.5}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8 }}>
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>. The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
</Typography>
<Stack spacing={2}>
{formatItems.map((item) => (
<Box key={item.num} sx={{ display: "flex", gap: 2 }}>
<Box sx={{ minWidth: 32, height: 32, borderRadius: "8px", background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>{item.num}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>{item.title}</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>{item.desc}</Typography>
</Box>
</Box>
))}
</Stack>
<Alert severity="info" sx={{ background: "rgba(99, 102, 241, 0.06)", border: "1px solid rgba(99, 102, 241, 0.15)", "& .MuiAlert-icon": { color: "#6366f1" } }}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences. The format will be preserved when rendering.
</Typography>
</Alert>
</Stack>
)}
</Paper>
);
};

View File

@@ -47,6 +47,8 @@ export type Research = {
provider?: string;
cost?: number;
sourceCount?: number;
expertQuotes?: { quote: string; source_index: number }[];
listenerCta?: string[];
};
export type Line = {
@@ -63,14 +65,18 @@ export type Scene = {
duration: number;
lines: Line[];
approved?: boolean;
emotion?: string; // Scene-specific emotion
audioUrl?: string; // Generated audio URL for this scene
imageUrl?: string; // Generated image URL for this scene (for video generation)
imagePrompt?: string; // Original image generation prompt for video context
emotion?: string;
audioUrl?: string;
imageUrl?: string;
imagePrompt?: string;
chart_data?: Record<string, any>;
broll_preview_url?: string;
};
export type Script = {
scenes: Scene[];
audioScript?: Scene[];
videoScript?: Scene[];
};
export type JobStatus =
@@ -129,8 +135,12 @@ export type PodcastEstimate = {
videoCost: number;
researchCost: number;
total: number;
voiceName?: string;
isCustomVoice?: boolean;
};
export type PodcastMode = "audio_only" | "video_only" | "audio_video";
export type HostPersona = {
name: string;
background: string;
@@ -170,6 +180,7 @@ export type CreateProjectPayload = {
budgetCap: number;
files: { voiceFile?: File | null; avatarFile?: File | null };
avatarUrl?: string | null;
podcastMode?: PodcastMode;
};
export type CreateProjectResult = {

View File

@@ -1,8 +1,36 @@
import React from "react";
import { motion } from "framer-motion";
import { Paper, alpha } from "@mui/material";
import { Paper, SxProps, Theme } from "@mui/material";
export const GlassyCard = motion.create(Paper);
interface GlassyCardProps {
children?: React.ReactNode;
sx?: SxProps<Theme>;
onClick?: () => void;
[key: string]: any; // Allow other props for framer-motion
}
export const GlassyCard: React.FC<GlassyCardProps> = ({ children, sx, ...props }) => {
return (
<Paper
sx={{
borderRadius: 3,
border: "1px solid rgba(15, 23, 42, 0.06)",
background: "#ffffff",
p: 3,
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
color: "#0f172a",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
"&:hover": {
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
borderColor: "rgba(15, 23, 42, 0.1)",
},
...sx
}}
{...props}
>
{children}
</Paper>
);
};
export const glassyCardSx = {
borderRadius: 3,

File diff suppressed because it is too large Load Diff