Files
ALwrity/frontend/src/components/PodcastMaker/PodcastDashboard.tsx

805 lines
29 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Paper,
Stack,
Typography,
Alert,
Chip,
Tooltip,
LinearProgress,
Stepper,
Step,
StepLabel,
Divider,
FormControl,
InputLabel,
Select,
MenuItem,
List,
ListItem,
ListItemButton,
ListItemText,
Checkbox,
CircularProgress,
alpha,
} from "@mui/material";
import {
Mic as MicIcon,
Psychology as PsychologyIcon,
Search as SearchIcon,
EditNote as EditNoteIcon,
PlayArrow as PlayArrowIcon,
CheckCircle as CheckCircleIcon,
Info as InfoIcon,
AutoAwesome as AutoAwesomeIcon,
Insights as InsightsIcon,
LibraryMusic as LibraryMusicIcon,
} from "@mui/icons-material";
import { ResearchProvider } from "../../services/blogWriterApi";
import { podcastApi } from "../../services/podcastApi";
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
import { useNavigate } from "react-router-dom";
import { CreateProjectPayload, Job, Knobs, Query, Research, Script } from "./types";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
import { CreateModal } from "./CreateModal";
import { AnalysisPanel } from "./AnalysisPanel";
import { FactCard } from "./FactCard";
import { ScriptEditor } from "./ScriptEditor";
import { RenderQueue } from "./RenderQueue";
import { RecentEpisodesPreview } from "./RecentEpisodesPreview";
import { ProjectList } from "./ProjectList";
import { usePreflightCheck } from "../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../hooks/useBudgetTracking";
import { PreflightBlockDialog } from "./PreflightBlockDialog";
import HeaderControls from "../shared/HeaderControls";
/* ================= Helpers ================= */
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
const message = error instanceof Error ? error.message : "Unexpected error";
setAnnouncement(message);
};
/* ================= Dashboard ================= */
const PodcastDashboard: React.FC = () => {
const navigate = useNavigate();
const projectState = usePodcastProjectState();
const [showProjectList, setShowProjectList] = useState(false);
const {
project,
analysis,
queries,
selectedQueries,
research,
rawResearch,
estimate,
scriptData,
renderJobs,
knobs: knobsState,
researchProvider,
showScriptEditor,
showRenderQueue,
currentStep,
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
updateRenderJob,
setKnobs,
setResearchProvider,
setBudgetCap,
setShowScriptEditor,
setShowRenderQueue,
initializeProject,
resetState,
loadProjectFromDb,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isResearching, setIsResearching] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showResumeAlert, setShowResumeAlert] = useState(false);
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
const [preflightResponse, setPreflightResponse] = useState<any>(null);
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
// Budget tracking
const budgetTracking = useBudgetTracking(projectState.budgetCap || 50);
// Preflight check hook
const preflightCheck = usePreflightCheck({
onBlocked: (response) => {
setPreflightResponse(response);
setShowPreflightDialog(true);
},
});
// Update budget cap when project state changes
useEffect(() => {
if (projectState.budgetCap) {
budgetTracking.setBudgetCap(projectState.budgetCap);
}
}, [projectState.budgetCap, budgetTracking]);
// Check if we have a saved project on mount
useEffect(() => {
if (project && currentStep && currentStep !== "create") {
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, []); // Only on mount
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = async (payload: CreateProjectPayload) => {
// Prevent duplicate submits that can spam story setup API
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
setKnobs(payload.knobs);
setBudgetCap(payload.budgetCap);
setAnnouncement("Analysis complete");
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsAnalyzing(false);
}
};
const handleRunResearch = async () => {
// Prevent duplicate research calls
if (isResearching) return;
if (!project) {
setAnnouncement("Create a project first.");
return;
}
if (selectedQueries.size === 0) {
setAnnouncement("Select at least one query to research.");
return;
}
// Preflight check before research
setPreflightOperationName("Research");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
const preflightResult = await preflightCheck.check({
provider: researchProvider === "exa" ? "exa" : "gemini",
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: researchProvider === "exa" ? 0 : 1200,
actual_provider_name: researchProvider || "google",
});
if (!preflightResult.can_proceed) {
return; // Dialog will be shown by onBlocked callback
}
try {
setIsResearching(true);
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
onProgress: (message) => {
// Update announcement with progress messages
setAnnouncement(message);
},
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research complete — review fact cards below");
} catch (researchError) {
const errorMessage = researchError instanceof Error
? researchError.message
: "Research failed. Please try again or switch to Standard Research.";
// Provide helpful error messages
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
} else if (errorMessage.includes("timeout")) {
setAnnouncement("Research timed out. Please try again with fewer queries.");
} else {
setAnnouncement(`Research failed: ${errorMessage}`);
}
// Log full error for debugging
console.error("Research error:", researchError);
throw researchError;
}
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsResearching(false);
}
};
const handleGenerateScript = async () => {
// Avoid re-triggering script generation preflight
if (showScriptEditor) return;
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
// Preflight check before script generation
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
return; // Dialog will be shown by onBlocked callback
}
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
};
const handleProceedToRendering = (script: Script) => {
setScriptData(script);
// Initialize render jobs if empty
if (renderJobs.length === 0) {
script.scenes.forEach((scene) => {
updateRenderJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: "idle" as const,
progress: 0,
previewUrl: null,
finalUrl: null,
jobId: null,
});
});
}
setShowRenderQueue(true);
setShowScriptEditor(false);
};
const selectedCount = selectedQueries.size;
const canGenerateScript = Boolean(project && research && rawResearch);
const toggleQuery = (id: string) => {
if (isResearching) return;
const current = selectedQueries;
const next = new Set<string>(current);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedQueries(next);
};
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (research) return 1;
if (analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, research, analysis]);
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" },
];
const handleSelectProject = async (projectId: string) => {
try {
await loadProjectFromDb(projectId);
setShowProjectList(false);
} catch (error) {
setAnnouncement(`Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`);
}
};
if (showProjectList) {
return <ProjectList onSelectProject={handleSelectProject} />;
}
return (
<Box
sx={{
minHeight: "100vh",
background: "#f8fafc",
p: { xs: 2, md: 4 },
}}
>
<Paper
elevation={0}
sx={{
maxWidth: 1400,
mx: "auto",
borderRadius: 3,
border: "1px solid rgba(0,0,0,0.08)",
background: "#ffffff",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
p: { xs: 3, md: 4 },
}}
>
<Stack spacing={3}>
{/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography
variant="h3"
sx={{
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary">
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={() => setShowProjectList(true)}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
>
My Projects
</SecondaryButton>
<PrimaryButton
onClick={() => {
resetState();
setShowProjectList(false);
}}
startIcon={<AutoAwesomeIcon />}
>
New Episode
</PrimaryButton>
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
{/* Progress Stepper */}
{project && activeStep >= 0 && (
<Paper
sx={{
p: 2.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 2,
}}
>
<Stepper activeStep={activeStep} orientation="horizontal" sx={{ "& .MuiStepLabel-root": { cursor: "pointer" } }}>
{steps.map((step, index) => (
<Step key={step.label} completed={index < activeStep}>
<StepLabel
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",
}}
>
{completed ? <CheckCircleIcon /> : step.icon}
</Box>
)}
>
<Typography variant="subtitle2">{step.label}</Typography>
<Typography variant="caption" color="text.secondary">
{step.description}
</Typography>
</StepLabel>
</Step>
))}
</Stepper>
</Paper>
)}
{/* Resume Alert */}
{showResumeAlert && project && (
<Alert
severity="success"
onClose={() => setShowResumeAlert(false)}
sx={{
background: "#d1fae5",
border: "1px solid #a7f3d0",
"& .MuiAlert-icon": { color: "#10b981" },
}}
>
<Typography variant="body2">
<strong>Project Restored:</strong> Resuming from{" "}
{currentStep === "analysis"
? "Analysis"
: currentStep === "research"
? "Research"
: currentStep === "script"
? "Script Editing"
: "Rendering"}{" "}
step. Your progress has been saved.
</Typography>
</Alert>
)}
{/* Announcements */}
{announcement && (
<Alert
severity="info"
onClose={() => setAnnouncement("")}
sx={{
background: "#dbeafe",
border: "1px solid #bfdbfe",
"& .MuiAlert-icon": { color: "#3b82f6" },
}}
>
{announcement}
</Alert>
)}
{(isAnalyzing || isResearching) && (
<Alert
severity="warning"
icon={<CircularProgress size={20} />}
sx={{
background: "#fef3c7",
border: "1px solid #fde68a",
}}
>
<Typography variant="body2">
{isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
</Typography>
</Alert>
)}
{/* Create Modal */}
{!project && (
<>
<CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} isSubmitting={isAnalyzing} />
{/* Recent Episodes Preview */}
<RecentEpisodesPreview onSelectEpisode={() => {}} />
</>
)}
{/* Main Content */}
<Stack spacing={3}>
{analysis && !showScriptEditor && !showRenderQueue && (
<AnalysisPanel
analysis={analysis}
onRegenerate={() => setAnalysis({ ...analysis })}
/>
)}
{estimate && !showScriptEditor && !showRenderQueue && (
<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: "#0f172a",
}}
aria-label="estimate"
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<InsightsIcon />
Estimated Cost
</Typography>
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
${estimate.total.toFixed(2)}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
title="Voice narration cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
title="Avatar/video cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
title="Research and fact-checking cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
</Stack>
</Stack>
</GlassyCard>
)}
{queries.length > 0 && !showScriptEditor && !showRenderQueue && (
<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: "#0f172a",
}}
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<SearchIcon />
Research Queries
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Provider</InputLabel>
<Select
value={researchProvider}
onChange={(e) => setResearchProvider(e.target.value as ResearchProvider)}
label="Provider"
disabled={isResearching}
size="small"
sx={{
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
}}
>
<MenuItem value="google">
<Stack direction="row" spacing={1} alignItems="center">
<SearchIcon fontSize="small" />
<span>Standard Research</span>
</Stack>
</MenuItem>
<MenuItem value="exa">
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeIcon fontSize="small" />
<span>Deep Research</span>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Chip
label={`${selectedCount} / ${queries.length} selected`}
size="small"
color={selectedCount > 0 ? "primary" : "default"}
/>
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Tooltip
title={
researchProvider === "google"
? "Standard Research: Fast, fact-checked results with source citations"
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
}
arrow
>
<Alert
severity="info"
sx={{
background: "#e0f2fe",
border: "1px solid #bae6fd",
color: "#0f172a",
}}
>
<Typography variant="caption" sx={{ color: "#0f172a" }}>
{researchProvider === "google"
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
</Typography>
</Alert>
</Tooltip>
<List>
{queries.map((q) => (
<ListItem key={q.id} disablePadding>
<ListItemButton
onClick={() => toggleQuery(q.id)}
disabled={isResearching}
sx={{
borderRadius: 2,
mb: 1,
border: "1px solid rgba(0,0,0,0.08)",
background: "#f8fafc",
"&:hover": { background: alpha("#667eea", 0.08) },
}}
>
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
<ListItemText
primary={q.query}
secondary={q.rationale}
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<PrimaryButton
onClick={handleRunResearch}
disabled={!project || selectedCount === 0 || isResearching}
loading={isResearching}
startIcon={<SearchIcon />}
tooltip={
selectedCount === 0
? "Select at least one query to run research"
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
}
>
{isResearching ? "Running Research..." : "Run Research"}
</PrimaryButton>
</Box>
</Stack>
</GlassyCard>
)}
{research && !showScriptEditor && !showRenderQueue && (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<InsightsIcon />
Research Summary
</Typography>
<Typography variant="body2" color="text.secondary">
{research.summary}
</Typography>
</Box>
<PrimaryButton
onClick={handleGenerateScript}
disabled={!canGenerateScript}
startIcon={<EditNoteIcon />}
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
>
Generate Script
</PrimaryButton>
</Stack>
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Fact Cards ({research.factCards.length})
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</Box>
</>
)}
</Stack>
</GlassyCard>
)}
{showScriptEditor && project && research && rawResearch && (
<ScriptEditor
projectId={project.id}
idea={project.idea}
research={research}
rawResearch={rawResearch}
knobs={knobsState}
speakers={project.speakers}
durationMinutes={project.duration}
script={scriptData}
onScriptChange={(s) => setScriptData(s)}
onBackToResearch={() => setShowScriptEditor(false)}
onProceedToRendering={(s) => handleProceedToRendering(s)}
onError={(msg) => setAnnouncement(msg)}
/>
)}
{showScriptEditor && (!research || !rawResearch) && (
<Alert severity="warning" sx={{ background: alpha("#f59e0b", 0.1), border: "1px solid rgba(245,158,11,0.3)" }}>
Complete a research run before opening the script editor.
</Alert>
)}
{showRenderQueue && project && scriptData && (
<RenderQueue
projectId={project.id}
script={scriptData}
knobs={knobsState}
jobs={renderJobs}
budgetCap={projectState.budgetCap}
avatarImageUrl={null}
onUpdateJob={updateRenderJob}
onBack={() => {
setShowRenderQueue(false);
setShowScriptEditor(true);
}}
onError={(msg) => setAnnouncement(msg)}
/>
)}
</Stack>
</Stack>
</Paper>
{/* Preflight Block Dialog */}
<PreflightBlockDialog
open={showPreflightDialog}
onClose={() => {
setShowPreflightDialog(false);
setPreflightResponse(null);
}}
response={preflightResponse}
operationName={preflightOperationName}
/>
</Box>
);
};
export default PodcastDashboard;