Podcast Maker: Fix progress modals, research JSON, header stepper, voice/podcastMode chips
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user