feat: podcast demo mode with ALWRITY_ENABLED_FEATURES support
- Add ALWRITY_ENABLED_FEATURES env var for feature gating - Podcast-only mode: skip LLM bootstrap, scheduler, persona services - Enhance video generation prompt with scene context, analysis, narration - Add voice cloning support via custom_voice_id in WaveSpeed - Add text-to-speech for research results (browser speechSynthesis) - Fix render queue to sync images from script phase - Add WaveSpeed LLM pricing (gpt-oss-120b) - Fix podcast bible generation error handling - Refactor RouterManager for feature-based router loading
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, TextField, IconButton, Select, MenuItem, FormControl, InputLabel, Switch, FormControlLabel } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon } from "@mui/icons-material";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, Button, Checkbox } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, RecordVoiceOver as VoiceIcon, Lightbulb as TipsIcon, Quiz as TalkIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis, PodcastEstimate } from "./types";
|
||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { aiApiClient } from "../../api/client";
|
||||
import { InputsTab, AudienceTab, OutlineTab, TitlesTab, HookTab, TakeawaysTab, GuestTab, CTATab } from "./AnalysisPanel/tabs";
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
analysis: PodcastAnalysis | null;
|
||||
@@ -16,6 +17,19 @@ interface AnalysisPanelProps {
|
||||
avatarPrompt?: string | null;
|
||||
onRegenerate?: () => void;
|
||||
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
|
||||
onRunResearch?: () => void;
|
||||
isResearchRunning?: boolean;
|
||||
selectedQueries?: Set<string>;
|
||||
onToggleQuery?: (queryId: string) => void;
|
||||
queries?: { id: string; query: string; rationale: string }[];
|
||||
}
|
||||
|
||||
type TabId = 'inputs' | 'audience' | 'content' | 'outline' | 'titles' | 'hook' | 'takeaways' | 'cta' | 'guest';
|
||||
|
||||
interface TabConfig {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const inputStyles = {
|
||||
@@ -54,8 +68,14 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
avatarUrl,
|
||||
avatarPrompt,
|
||||
onRegenerate,
|
||||
onUpdateAnalysis
|
||||
onUpdateAnalysis,
|
||||
onRunResearch,
|
||||
isResearchRunning,
|
||||
selectedQueries,
|
||||
onToggleQuery,
|
||||
queries
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('inputs');
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
@@ -64,6 +84,38 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
|
||||
|
||||
const tabs: TabConfig[] = [
|
||||
{ id: 'inputs', label: 'Your Inputs', icon: <InputIcon /> },
|
||||
{ id: 'audience', label: 'Audience', icon: <GroupsIcon /> },
|
||||
{ id: 'content', label: 'Content', icon: <ListAltIcon /> },
|
||||
{ id: 'outline', label: 'Outline', icon: <ListAltIcon /> },
|
||||
{ id: 'titles', label: 'Titles', icon: <EditNoteIcon /> },
|
||||
{ id: 'hook', label: 'Hook', icon: <AutoAwesomeIcon /> },
|
||||
{ id: 'takeaways', label: 'Takeaways', icon: <TipsIcon /> },
|
||||
{ id: 'guest', label: 'Guest', icon: <PersonIcon /> },
|
||||
{ id: 'cta', label: 'CTA', icon: <VoiceIcon /> },
|
||||
];
|
||||
|
||||
const tabButtonStyles = (isActive: boolean) => ({
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: "transparent",
|
||||
color: isActive ? "#fff" : "#64748b",
|
||||
border: isActive ? "none" : "1px solid rgba(0,0,0,0.1)",
|
||||
borderRadius: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "none" as const,
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
|
||||
: "rgba(102,126,234,0.08)",
|
||||
},
|
||||
});
|
||||
|
||||
// Sync editedAnalysis with analysis initially
|
||||
useEffect(() => {
|
||||
if (analysis && !editedAnalysis) {
|
||||
@@ -325,622 +377,183 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
|
||||
{/* Inputs Section */}
|
||||
{(idea || duration || speakers || avatarUrl || avatarPrompt) && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
{/* AI Futuristic Tab Navigation */}
|
||||
<Stack direction="row" flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
startIcon={tab.icon}
|
||||
sx={tabButtonStyles(activeTab === tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Tab Content */}
|
||||
<Box sx={{ minHeight: 300 }}>
|
||||
{activeTab === 'inputs' && (
|
||||
<InputsTab
|
||||
idea={idea}
|
||||
duration={duration}
|
||||
speakers={speakers}
|
||||
avatarUrl={avatarUrl}
|
||||
avatarPrompt={avatarPrompt}
|
||||
avatarBlobUrl={avatarBlobUrl}
|
||||
avatarLoading={avatarLoading}
|
||||
avatarError={avatarError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'audience' && (
|
||||
<AudienceTab
|
||||
analysis={currentAnalysis}
|
||||
isEditing={isEditing}
|
||||
editedAnalysis={editedAnalysis}
|
||||
setEditedAnalysis={setEditedAnalysis}
|
||||
handleRemoveKeyword={handleRemoveKeyword}
|
||||
handleAddKeyword={handleAddKeyword}
|
||||
handleRemoveTitle={handleRemoveTitle}
|
||||
handleAddTitle={handleAddTitle}
|
||||
handleUpdateOutline={handleUpdateOutline}
|
||||
updateExaConfig={updateExaConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'outline' && (
|
||||
<OutlineTab
|
||||
analysis={currentAnalysis}
|
||||
isEditing={isEditing}
|
||||
onUpdateOutline={handleUpdateOutline}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'titles' && (
|
||||
<TitlesTab
|
||||
analysis={currentAnalysis}
|
||||
isEditing={isEditing}
|
||||
handleRemoveTitle={handleRemoveTitle}
|
||||
handleAddTitle={handleAddTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'hook' && (
|
||||
<HookTab analysis={currentAnalysis} />
|
||||
)}
|
||||
|
||||
{activeTab === 'takeaways' && (
|
||||
<TakeawaysTab analysis={currentAnalysis} />
|
||||
)}
|
||||
|
||||
{activeTab === 'guest' && (
|
||||
<GuestTab analysis={currentAnalysis} />
|
||||
)}
|
||||
|
||||
{activeTab === 'cta' && (
|
||||
<CTATab analysis={currentAnalysis} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Research Section - Separate from tabs */}
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)", my: 2 }} />
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<SearchIcon sx={{ color: "#4f46e5" }} />
|
||||
Research Queries
|
||||
{selectedQueries && selectedQueries.size > 0 && (
|
||||
<Chip
|
||||
label={`${selectedQueries.size} selected`}
|
||||
size="small"
|
||||
sx={{ ml: 1, height: 20, fontSize: "0.65rem", bgcolor: "#4f46e5", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
{onRunResearch && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={onRunResearch}
|
||||
disabled={isResearchRunning || !selectedQueries || selectedQueries.size === 0}
|
||||
startIcon={isResearchRunning ? <CircularProgress size={16} color="inherit" /> : <SearchIcon />}
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
mb: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "#94a3b8",
|
||||
}
|
||||
}}
|
||||
>
|
||||
Your Inputs
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
|
||||
gap: 3,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* Left Column: Text Inputs */}
|
||||
<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">
|
||||
{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} ${speakers === 1 ? "speaker" : "speakers"}`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* AI Prompt Used for Avatar Generation */}
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
{isResearchRunning ? "Running..." : "Run Research"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!analysis?.research_queries || analysis.research_queries.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontStyle: "italic" }}>
|
||||
No research queries yet. Click "Regenerate Analysis" to generate research queries based on your podcast idea.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.5}>
|
||||
{(queries || analysis.research_queries?.map((rq, idx) => ({ id: `query-${idx}`, ...rq }))).map((rq: { id: string; query: string; rationale: string }, idx: number) => {
|
||||
const queryId = rq.id;
|
||||
const isSelected = selectedQueries?.has(queryId) || false;
|
||||
return (
|
||||
<Paper
|
||||
key={idx}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: isSelected ? "#f0f9ff" : "#f8fafc",
|
||||
border: `1px solid ${isSelected ? 'rgba(79,70,229,0.4)' : 'rgba(0,0,0,0.08)'}`,
|
||||
borderRadius: 2,
|
||||
transition: "all 0.2s ease",
|
||||
cursor: onToggleQuery ? "pointer" : "default",
|
||||
"&:hover": onToggleQuery ? {
|
||||
borderColor: "rgba(79,70,229,0.3)",
|
||||
bgcolor: "#f8fafc"
|
||||
} : {}
|
||||
}}
|
||||
onClick={() => onToggleQuery?.(queryId)}
|
||||
>
|
||||
<Stack direction="row" alignItems="flex-start" gap={1.5}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleQuery?.(queryId)}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 0.75,
|
||||
"&.Mui-checked": {
|
||||
color: "#4f46e5",
|
||||
},
|
||||
padding: 0.5,
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
|
||||
AI Generation Prompt
|
||||
</Typography>
|
||||
{avatarPrompt ? (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#475569",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{avatarPrompt}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f1f5f9",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontStyle: "italic",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Prompt not available (avatar was uploaded or generated before this feature was added)
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Right Column: Presenter Avatar */}
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ fontSize: 16 }} />
|
||||
Presenter Avatar
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: { xs: "100%", md: 300 },
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
position: "relative",
|
||||
aspectRatio: "1",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{avatarLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : avatarError ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#fef2f2",
|
||||
color: "#dc2626",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ textAlign: "center" }}>
|
||||
Failed to load avatar
|
||||
</Typography>
|
||||
</Box>
|
||||
) : avatarBlobUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarBlobUrl}
|
||||
alt="Podcast Presenter"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('[AnalysisPanel] Avatar image failed to load:', {
|
||||
src: e.currentTarget.src,
|
||||
avatarUrl,
|
||||
avatarBlobUrl,
|
||||
});
|
||||
setAvatarError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('[AnalysisPanel] Avatar image loaded successfully');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<InsightsIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Target Audience
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
size="small"
|
||||
value={currentAnalysis.audience}
|
||||
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, audience: e.target.value })}
|
||||
placeholder="Describe your target audience..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#0f172a" }}>
|
||||
{currentAnalysis.audience}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Content Type</Typography>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={currentAnalysis.contentType}
|
||||
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, contentType: e.target.value })}
|
||||
placeholder="e.g. Interview, Narrative, Solo..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Chip label={currentAnalysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Top Keywords</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.topKeywords.map((k) => (
|
||||
<Chip
|
||||
key={k}
|
||||
label={k}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onDelete={isEditing ? () => handleRemoveKeyword(k) : undefined}
|
||||
sx={{
|
||||
borderColor: "rgba(0,0,0,0.1)",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add keyword and press Enter..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddKeyword((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddKeyword(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#111827", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<EditNoteIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Suggested Episode Outlines
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{currentAnalysis.suggestedOutlines.map((o) => (
|
||||
<Paper
|
||||
key={o.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
background: isEditing ? "#ffffff" : "#f8fafc",
|
||||
border: "1px solid",
|
||||
borderColor: isEditing ? "#e2e8f0" : "rgba(0,0,0,0.04)",
|
||||
borderRadius: 2,
|
||||
wordBreak: "break-word",
|
||||
position: 'relative',
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
borderColor: "#4f46e5",
|
||||
boxShadow: "0 4px 12px rgba(79, 70, 229, 0.05)"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Outline Title"
|
||||
value={o.title}
|
||||
onChange={(e) => handleUpdateOutline(o.id, 'title', e.target.value)}
|
||||
sx={inputStyles}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
size="small"
|
||||
label="Segments"
|
||||
value={o.segments.join(' • ')}
|
||||
onChange={(e) => handleUpdateOutline(o.id, 'segments', e.target.value.split(/•|,/).map(s => s.trim()).filter(Boolean))}
|
||||
helperText="Use • or comma to separate segments"
|
||||
sx={inputStyles}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body1" sx={{ fontWeight: 800, mb: 1, color: "#111827" }}>
|
||||
{o.title}
|
||||
/>
|
||||
<Chip label={idx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
{rq.query}
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{o.segments.map((segment, idx) => (
|
||||
<Box key={idx} sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box sx={{ mt: 1, width: 6, height: 6, borderRadius: "50%", bgcolor: "#4f46e5", flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: "#4b5563", lineHeight: 1.5 }}>
|
||||
{segment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Rationale: {rq.rationale}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{currentAnalysis.exaSuggestedConfig && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Exa Research Suggestions
|
||||
</Typography>
|
||||
|
||||
{isEditing ? (
|
||||
<Stack spacing={2} sx={{ p: 2, border: '1px solid #e2e8f0', borderRadius: 2, bgcolor: '#ffffff' }}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Search Type</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.exa_search_type || 'auto'}
|
||||
label="Search Type"
|
||||
onChange={(e) => updateExaConfig('exa_search_type', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">Auto</MenuItem>
|
||||
<MenuItem value="neural">Neural</MenuItem>
|
||||
<MenuItem value="keyword">Keyword</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Category</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.exa_category || 'news'}
|
||||
label="Category"
|
||||
onChange={(e) => updateExaConfig('exa_category', e.target.value)}
|
||||
>
|
||||
<MenuItem value="news">News</MenuItem>
|
||||
<MenuItem value="research paper">Research Paper</MenuItem>
|
||||
<MenuItem value="company">Company</MenuItem>
|
||||
<MenuItem value="pdf">PDF</MenuItem>
|
||||
<MenuItem value="tweet">Tweet</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Date Range</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.date_range || 'all_time'}
|
||||
label="Date Range"
|
||||
onChange={(e) => updateExaConfig('date_range', e.target.value)}
|
||||
>
|
||||
<MenuItem value="all_time">All Time</MenuItem>
|
||||
<MenuItem value="last_month">Last Month</MenuItem>
|
||||
<MenuItem value="last_year">Last Year</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Max Sources"
|
||||
size="small"
|
||||
value={currentAnalysis.exaSuggestedConfig.max_sources || 10}
|
||||
onChange={(e) => updateExaConfig('max_sources', parseInt(e.target.value))}
|
||||
sx={{ ...inputStyles, width: 120 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={currentAnalysis.exaSuggestedConfig.include_statistics || false}
|
||||
onChange={(e) => updateExaConfig('include_statistics', e.target.checked)}
|
||||
sx={{ '& .MuiSwitch-track': { bgcolor: '#4f46e5' } }}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2" sx={{ color: '#111827', fontWeight: 500 }}>Include Statistics</Typography>}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Prefer Domains"
|
||||
placeholder="e.g. techcrunch.com, wired.com (press Enter)"
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val) {
|
||||
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains || [];
|
||||
updateExaConfig('exa_include_domains', [...domains, val]);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{(currentAnalysis.exaSuggestedConfig.exa_include_domains || []).map(d => (
|
||||
<Chip key={d} label={d} size="small" onDelete={() => {
|
||||
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains?.filter(item => item !== d);
|
||||
updateExaConfig('exa_include_domains', domains);
|
||||
}} sx={{ bgcolor: '#f3f4f6', color: '#111827' }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_search_type && (
|
||||
<Chip
|
||||
label={`Search: ${currentAnalysis.exaSuggestedConfig.exa_search_type}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.exa_category && (
|
||||
<Chip
|
||||
label={`Category: ${currentAnalysis.exaSuggestedConfig.exa_category}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.date_range && (
|
||||
<Chip
|
||||
label={`Date: ${currentAnalysis.exaSuggestedConfig.date_range}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{typeof currentAnalysis.exaSuggestedConfig.include_statistics === "boolean" && (
|
||||
<Chip
|
||||
label={currentAnalysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.max_sources && (
|
||||
<Chip
|
||||
label={`Max sources: ${currentAnalysis.exaSuggestedConfig.max_sources}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{(currentAnalysis.exaSuggestedConfig.exa_include_domains?.length || currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_include_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Prefer domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_include_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Avoid domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.titleSuggestions.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
size="small"
|
||||
onDelete={isEditing ? () => handleRemoveTitle(t) : undefined}
|
||||
sx={{
|
||||
cursor: isEditing ? "default" : "pointer",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
lineHeight: 1.3,
|
||||
"& .MuiChip-label": {
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
textAlign: "left",
|
||||
paddingTop: 0.25,
|
||||
paddingBottom: 0.25,
|
||||
},
|
||||
"&:hover": isEditing ? {} : {
|
||||
background: alpha("#667eea", 0.15),
|
||||
border: "1px solid rgba(102,126,234,0.35)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add title suggestion..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTitle((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddTitle(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Stack, Button, Typography, Box } from "@mui/material";
|
||||
import { Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, EditNote as EditNoteIcon, Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Lightbulb as TipsIcon, Quiz as TalkIcon, RecordVoiceOver as VoiceIcon } from "@mui/icons-material";
|
||||
|
||||
export type TabId = "inputs" | "audience" | "content" | "outline" | "titles" | "research" | "hook" | "takeaways" | "guest" | "cta";
|
||||
|
||||
interface TabConfig {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ANALYSIS_TABS: TabConfig[] = [
|
||||
{ id: "inputs", label: "Your Inputs", icon: <InputIcon /> },
|
||||
{ id: "audience", label: "Audience", icon: <GroupsIcon /> },
|
||||
{ id: "content", label: "Content", icon: <ListAltIcon /> },
|
||||
{ id: "outline", label: "Outline", icon: <ListAltIcon /> },
|
||||
{ id: "titles", label: "Titles", icon: <EditNoteIcon /> },
|
||||
{ id: "research", label: "Research", icon: <SearchIcon /> },
|
||||
{ id: "hook", label: "Hook", icon: <AutoAwesomeIcon /> },
|
||||
{ id: "takeaways", label: "Takeaways", icon: <TipsIcon /> },
|
||||
{ id: "guest", label: "Guest", icon: <TalkIcon /> },
|
||||
{ id: "cta", label: "CTA", icon: <VoiceIcon /> },
|
||||
];
|
||||
|
||||
const getTabButtonStyles = (isActive: boolean) => ({
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: "transparent",
|
||||
color: isActive ? "#fff" : "#64748b",
|
||||
border: isActive ? "none" : "1px solid rgba(0,0,0,0.1)",
|
||||
borderRadius: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "none" as const,
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
|
||||
: "rgba(102,126,234,0.08)",
|
||||
},
|
||||
});
|
||||
|
||||
interface AnalysisTabNavProps {
|
||||
activeTab: TabId;
|
||||
onTabChange: (tab: TabId) => void;
|
||||
}
|
||||
|
||||
export const AnalysisTabNav: React.FC<AnalysisTabNavProps> = ({ activeTab, onTabChange }) => {
|
||||
return (
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
{ANALYSIS_TABS.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
startIcon={tab.icon}
|
||||
sx={getTabButtonStyles(activeTab === tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnalysisTabContent: React.FC<{ children: React.ReactNode; title?: string; icon?: React.ReactNode }> = ({
|
||||
children,
|
||||
title,
|
||||
icon,
|
||||
}) => (
|
||||
<Box>
|
||||
{title && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, TextField, IconButton, Paper, Divider } from "@mui/material";
|
||||
import { Groups as GroupsIcon, Insights as InsightsIcon, Search as SearchIcon, EditNote as EditNoteIcon, Add as AddIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface AudienceTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
isEditing?: boolean;
|
||||
editedAnalysis?: PodcastAnalysis | null;
|
||||
setEditedAnalysis?: (analysis: PodcastAnalysis) => void;
|
||||
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;
|
||||
updateExaConfig?: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
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 AudienceTab: React.FC<AudienceTabProps> = ({
|
||||
analysis,
|
||||
isEditing,
|
||||
editedAnalysis,
|
||||
setEditedAnalysis,
|
||||
handleRemoveKeyword,
|
||||
handleAddKeyword,
|
||||
handleRemoveTitle,
|
||||
handleAddTitle,
|
||||
handleUpdateOutline,
|
||||
updateExaConfig
|
||||
}) => {
|
||||
const currentAnalysis = editedAnalysis || analysis;
|
||||
|
||||
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={currentAnalysis.audience}
|
||||
onChange={(e) => setEditedAnalysis?.({ ...currentAnalysis, audience: e.target.value })}
|
||||
placeholder="Describe your target audience..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#0f172a" }}>
|
||||
{currentAnalysis.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={currentAnalysis.contentType}
|
||||
onChange={(e) => setEditedAnalysis?.({ ...currentAnalysis, contentType: e.target.value })}
|
||||
placeholder="e.g. Interview, Narrative, Solo..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Chip label={currentAnalysis.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 }}>
|
||||
{currentAnalysis.topKeywords.map((k: string) => (
|
||||
<Chip
|
||||
key={k}
|
||||
label={k}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onDelete={isEditing ? () => handleRemoveKeyword?.(k) : undefined}
|
||||
sx={{
|
||||
borderColor: "rgba(0,0,0,0.1)",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add keyword and press Enter..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddKeyword?.((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddKeyword?.(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{currentAnalysis.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>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_search_type && (
|
||||
<Chip label={`Search: ${currentAnalysis.exaSuggestedConfig.exa_search_type}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.exa_category && (
|
||||
<Chip label={`Category: ${currentAnalysis.exaSuggestedConfig.exa_category}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.date_range && (
|
||||
<Chip label={`Date: ${currentAnalysis.exaSuggestedConfig.date_range}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.max_sources && (
|
||||
<Chip label={`Max: ${currentAnalysis.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 }}>
|
||||
{currentAnalysis.titleSuggestions.map((t: string) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
size="small"
|
||||
onDelete={isEditing ? () => handleRemoveTitle?.(t) : undefined}
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add title suggestion..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTitle?.((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddTitle?.(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Paper } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface CTATabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const CTATab: React.FC<CTATabProps> = ({ analysis }) => {
|
||||
if (!analysis.listener_cta) {
|
||||
return (
|
||||
<AnalysisTabContent title="Listener CTA" icon={<PsychologyIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No listener call-to-action generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Listener CTA" icon={<PsychologyIcon />}>
|
||||
<Paper elevation={0} sx={{ p: 3, bgcolor: "#fff7ed", border: "1px solid rgba(249,115,22,0.2)", borderRadius: 2 }}>
|
||||
<Typography variant="body1" sx={{ color: "#c2410c", fontWeight: 500, lineHeight: 1.6 }}>
|
||||
{analysis.listener_cta}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", mt: 1, display: "block" }}>
|
||||
This is a call-to-action for listeners to take action after the episode.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||
import { Quiz as TalkIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface GuestTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const GuestTab: React.FC<GuestTabProps> = ({ analysis }) => {
|
||||
if (!analysis.guest_talking_points || analysis.guest_talking_points.length === 0) {
|
||||
return (
|
||||
<AnalysisTabContent title="Guest Talking Points" icon={<TalkIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No guest talking points generated yet. Add a guest speaker to get interview questions.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Guest Talking Points" icon={<TalkIcon />}>
|
||||
<Stack spacing={2}>
|
||||
{analysis.guest_talking_points.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>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Paper } from "@mui/material";
|
||||
import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface HookTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const HookTab: React.FC<HookTabProps> = ({ analysis }) => {
|
||||
if (!analysis.episode_hook) {
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Hook" icon={<AutoAwesomeIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No episode hook generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Hook" icon={<AutoAwesomeIcon />}>
|
||||
<Paper elevation={0} sx={{ p: 3, bgcolor: "#f0f9ff", border: "1px solid rgba(59,130,246,0.2)", borderRadius: 2 }}>
|
||||
<Typography variant="body1" sx={{ color: "#0369a1", fontStyle: "italic", fontSize: "1.1rem", lineHeight: 1.6 }}>
|
||||
"{analysis.episode_hook}"
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", mt: 1, display: "block" }}>
|
||||
This is a 15-30 second opening hook to grab listener attention.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography, Chip, Paper, CircularProgress, alpha } from "@mui/material";
|
||||
import { Input as InputIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface InputsTabProps {
|
||||
idea?: string;
|
||||
duration?: number;
|
||||
speakers?: number;
|
||||
avatarUrl?: string | null;
|
||||
avatarPrompt?: string | null;
|
||||
avatarBlobUrl?: string | null;
|
||||
avatarLoading?: boolean;
|
||||
avatarError?: boolean;
|
||||
}
|
||||
|
||||
export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers, avatarUrl, avatarPrompt, avatarBlobUrl, avatarLoading, avatarError }) => {
|
||||
if (!idea && !duration && !speakers && !avatarUrl && !avatarPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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">
|
||||
{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} ${speakers === 1 ? "speaker" : "speakers"}`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{avatarPrompt && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
|
||||
AI Generation Prompt
|
||||
</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#475569",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{avatarPrompt}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ fontSize: 16 }} />
|
||||
Presenter Avatar
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: { xs: "100%", md: 300 },
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
position: "relative",
|
||||
aspectRatio: "1",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{avatarLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : avatarError ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#fef2f2",
|
||||
color: "#dc2626",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ textAlign: "center" }}>
|
||||
Failed to load avatar
|
||||
</Typography>
|
||||
</Box>
|
||||
) : avatarBlobUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarBlobUrl}
|
||||
alt="Podcast Presenter"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, TextField, IconButton } from "@mui/material";
|
||||
import { ListAlt as ListAltIcon, Add as AddIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface OutlineTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
isEditing?: boolean;
|
||||
onUpdateOutline?: (id: string | number, field: 'title' | 'segments', value: any) => void;
|
||||
}
|
||||
|
||||
export const OutlineTab: React.FC<OutlineTabProps> = ({ analysis, isEditing, onUpdateOutline }) => {
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Outline" icon={<ListAltIcon />}>
|
||||
<Stack spacing={3}>
|
||||
{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)" }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 700 }}>
|
||||
Option {idx + 1}: {outline.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{outline.segments?.map((segment: string, sIdx: number) => (
|
||||
<Box key={sIdx} sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}>
|
||||
<Chip label={sIdx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
|
||||
<Typography variant="body2" sx={{ color: "#475569" }}>
|
||||
{segment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||
import { Search as SearchIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface ResearchTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const ResearchTab: React.FC<ResearchTabProps> = ({ analysis }) => {
|
||||
if (!analysis.research_queries || analysis.research_queries.length === 0) {
|
||||
return (
|
||||
<AnalysisTabContent title="Research Queries" icon={<SearchIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No research queries generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Research Queries" icon={<SearchIcon />}>
|
||||
<Stack spacing={2}>
|
||||
{analysis.research_queries.map((rq: { query: string; rationale: string }, idx: number) => (
|
||||
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#f8fafc", border: "1px solid rgba(0,0,0,0.08)", borderRadius: 2 }}>
|
||||
<Stack direction="row" alignItems="flex-start" gap={1.5}>
|
||||
<Chip label={idx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
{rq.query}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Rationale: {rq.rationale}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||
import { Lightbulb as TipsIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface TakeawaysTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const TakeawaysTab: React.FC<TakeawaysTabProps> = ({ analysis }) => {
|
||||
if (!analysis.key_takeaways || analysis.key_takeaways.length === 0) {
|
||||
return (
|
||||
<AnalysisTabContent title="Key Takeaways" icon={<TipsIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No key takeaways generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Key Takeaways" icon={<TipsIcon />}>
|
||||
<Stack spacing={2}>
|
||||
{analysis.key_takeaways.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>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, TextField, IconButton } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, Add as AddIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface TitlesTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
isEditing?: boolean;
|
||||
handleRemoveTitle?: (title: string) => void;
|
||||
handleAddTitle?: (title: string) => void;
|
||||
}
|
||||
|
||||
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 TitlesTab: React.FC<TitlesTabProps> = ({ analysis, isEditing, handleRemoveTitle, handleAddTitle }) => {
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Titles" icon={<EditNoteIcon />}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{analysis.titleSuggestions?.map((title: string, idx: number) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={title}
|
||||
size="small"
|
||||
onDelete={isEditing ? () => handleRemoveTitle?.(title) : undefined}
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add title suggestion..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTitle?.((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddTitle?.(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export { HookTab } from "./HookTab";
|
||||
export { CTATab } from "./CTATab";
|
||||
export { GuestTab } from "./GuestTab";
|
||||
export { TakeawaysTab } from "./TakeawaysTab";
|
||||
export { ResearchTab } from "./ResearchTab";
|
||||
export { TitlesTab } from "./TitlesTab";
|
||||
export { OutlineTab } from "./OutlineTab";
|
||||
export { AudienceTab } from "./AudienceTab";
|
||||
export { InputsTab } from "./InputsTab";
|
||||
@@ -5,6 +5,7 @@ import { useSubscription } from "../../contexts/SubscriptionContext";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
|
||||
import { getLatestBrandAvatar } from "../../api/brandAssets";
|
||||
import { VoiceSelector } from "../shared/VoiceSelector";
|
||||
|
||||
// Imported Components
|
||||
import { CreateHeader } from "./CreateStep/CreateHeader";
|
||||
@@ -43,6 +44,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
const [enhancingTopic, setEnhancingTopic] = useState(false);
|
||||
const [enhanceTopicProgressIndex, setEnhanceTopicProgressIndex] = useState(0);
|
||||
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("Wise_Woman");
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
const [avatarTab, setAvatarTab] = useState(0);
|
||||
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
|
||||
@@ -318,11 +320,17 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
}
|
||||
|
||||
// Include selected voice in knobs
|
||||
const finalKnobs = {
|
||||
...knobs,
|
||||
voice_id: selectedVoiceId,
|
||||
};
|
||||
|
||||
onCreate({
|
||||
ideaOrUrl: finalUrl || finalIdea,
|
||||
speakers,
|
||||
duration,
|
||||
knobs,
|
||||
knobs: finalKnobs,
|
||||
budgetCap,
|
||||
files: { voiceFile, avatarFile },
|
||||
avatarUrl: finalAvatarUrl,
|
||||
@@ -342,6 +350,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setEnhancingTopic(false);
|
||||
setEnhanceTopicProgressIndex(0);
|
||||
setKnobs({ ...defaultKnobs });
|
||||
setSelectedVoiceId("Wise_Woman");
|
||||
setPlaceholderIndex(0);
|
||||
};
|
||||
|
||||
@@ -565,6 +574,12 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setCameraSelfieOpen={setCameraSelfieOpen}
|
||||
/>
|
||||
|
||||
<VoiceSelector
|
||||
value={selectedVoiceId}
|
||||
onChange={setSelectedVoiceId}
|
||||
showVoiceClone={true}
|
||||
/>
|
||||
|
||||
<CreateActions
|
||||
reset={reset}
|
||||
submit={submit}
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Alert, Typography, alpha, IconButton, Collapse } from "@mui/material";
|
||||
import {
|
||||
Stack,
|
||||
Alert,
|
||||
Typography,
|
||||
alpha,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
CircularProgress,
|
||||
Box,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
Refresh as RefreshIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Title as TitleIcon,
|
||||
ListAlt as ListAltIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
RecordVoiceOver as RecordVoiceOverIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
|
||||
@@ -16,64 +37,191 @@ interface CreateActionsProps {
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export const CreateActions: React.FC<CreateActionsProps> = ({
|
||||
reset,
|
||||
submit,
|
||||
canSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
// ============================================================================
|
||||
// Constants & Data
|
||||
// ============================================================================
|
||||
|
||||
const ANALYSIS_FEATURES = [
|
||||
{ icon: <AnalyticsIcon />, text: "Target audience & content type analysis" },
|
||||
{ icon: <ListAltIcon />, text: "5 high-impact keywords for discoverability" },
|
||||
{ icon: <TitleIcon />, text: "3 catchy episode title suggestions" },
|
||||
{ icon: <PsychologyIcon />, text: "2 detailed episode outlines with segments" },
|
||||
{ icon: <RecordVoiceOverIcon />, text: "4-6 research queries for AI-powered research" },
|
||||
{ icon: <CheckCircleIcon />, text: "Episode hook, key takeaways & listener CTA" },
|
||||
];
|
||||
|
||||
const ANALYSIS_PROGRESS_STEPS = [
|
||||
"Analyzing target audience & content type",
|
||||
"Generating keywords & title suggestions",
|
||||
"Creating episode outlines",
|
||||
"Generating research queries",
|
||||
"Creating hook, takeaways & CTA",
|
||||
];
|
||||
|
||||
const INFO_BANNER_TEXT =
|
||||
"Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically.";
|
||||
|
||||
// ============================================================================
|
||||
// Styles
|
||||
// ============================================================================
|
||||
|
||||
const styles = {
|
||||
dialog: {
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(167, 139, 250, 0.3)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
infoAlert: {
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
||||
},
|
||||
progressDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
bgcolor: "#a78bfa",
|
||||
},
|
||||
dialogContent: {
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
minHeight: 200,
|
||||
py: 3,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sub-Components
|
||||
// ============================================================================
|
||||
|
||||
const InfoBanner: React.FC<{ showInfo: boolean; setShowInfo: (v: boolean) => void }> = ({
|
||||
showInfo,
|
||||
setShowInfo,
|
||||
}) => (
|
||||
<Collapse in={showInfo}>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sx={styles.infoAlert}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
|
||||
{INFO_BANNER_TEXT}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
);
|
||||
|
||||
const ShowTipsLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<InfoIcon sx={{ fontSize: 16, color: "#6366f1" }} />
|
||||
<Typography variant="caption" sx={{ color: "#6366f1", cursor: "pointer", "&:hover": { textDecoration: "underline" } }} onClick={onClick}>
|
||||
Show tips
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const AnalysisProgressView: React.FC = () => (
|
||||
<Stack spacing={3} alignItems="center" sx={styles.dialogContent} justifyContent="center">
|
||||
<Box sx={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<CircularProgress size={80} thickness={3} sx={{ color: "#a78bfa" }} />
|
||||
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: 32 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Analyzing Your Podcast Idea
|
||||
</Typography>
|
||||
|
||||
<LinearProgress
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
bgcolor: "rgba(255,255,255,0.1)",
|
||||
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 4 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack spacing={1} sx={{ width: "100%" }}>
|
||||
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", textAlign: "center" }}>
|
||||
This may take a few moments...
|
||||
</Typography>
|
||||
<Stack spacing={0.5} alignItems="flex-start" sx={{ pl: 2 }}>
|
||||
{ANALYSIS_PROGRESS_STEPS.map((step, idx) => (
|
||||
<Typography key={idx} variant="caption" sx={{ color: "rgba(255,255,255,0.5)", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Box sx={styles.progressDot} /> {step}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const WhatYoullGetView: React.FC = () => (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)" }}>
|
||||
Click "Start Analysis" to begin AI-powered podcast planning. Here's what we'll generate for you:
|
||||
</Typography>
|
||||
<List>
|
||||
{ANALYSIS_FEATURES.map((feature, index) => (
|
||||
<ListItem key={index} sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: "#a78bfa" }}>{feature.icon}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={feature.text}
|
||||
primaryTypographyProps={{ sx: { color: "rgba(255,255,255,0.9)", fontSize: "0.9rem" } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting }) => {
|
||||
const [showInfo, setShowInfo] = useState(true);
|
||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||
const [analysisStarted, setAnalysisStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setShowInfo(false);
|
||||
}, 8000);
|
||||
const timer = setTimeout(() => setShowInfo(false), 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Close modal when analysis completes
|
||||
useEffect(() => {
|
||||
if (!isSubmitting && analysisStarted) {
|
||||
setShowAnalysisModal(false);
|
||||
setAnalysisStarted(false);
|
||||
}
|
||||
}, [isSubmitting, analysisStarted]);
|
||||
|
||||
const handleSubmitClick = () => {
|
||||
if (canSubmit && !isSubmitting) setShowAnalysisModal(true);
|
||||
};
|
||||
|
||||
const handleStartAnalysis = () => {
|
||||
setAnalysisStarted(true);
|
||||
submit();
|
||||
};
|
||||
|
||||
const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{/* Collapsible Info Banner */}
|
||||
<Collapse in={showInfo}>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sx={{
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
||||
"& .MuiAlert-message": {
|
||||
width: "100%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
|
||||
Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
{!showInfo && (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<InfoIcon sx={{ fontSize: 16, color: "#6366f1" }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "#6366f1", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}
|
||||
onClick={() => setShowInfo(true)}
|
||||
>
|
||||
Show tips
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
<InfoBanner showInfo={showInfo} setShowInfo={setShowInfo} />
|
||||
{!showInfo && <ShowTipsLink onClick={() => setShowInfo(true)} />}
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1}>
|
||||
<SecondaryButton onClick={reset} startIcon={<RefreshIcon />}>
|
||||
Reset
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={submit}
|
||||
onClick={handleSubmitClick}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
@@ -82,6 +230,43 @@ export const CreateActions: React.FC<CreateActionsProps> = ({
|
||||
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
<Dialog
|
||||
open={showAnalysisModal}
|
||||
onClose={() => !isSubmitting && setShowAnalysisModal(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: styles.dialog }}
|
||||
>
|
||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{isSubmitting ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CircularProgress size={24} sx={{ color: "#a78bfa" }} />
|
||||
Analyzing Your Podcast Idea
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<AutoAwesomeIcon sx={{ color: "#a78bfa" }} />
|
||||
What You'll Get
|
||||
</Box>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={styles.dialogContent}>
|
||||
{showProgressInModal ? <AnalysisProgressView /> : <WhatYoullGetView />}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
{showProgressInModal ? null : (
|
||||
<>
|
||||
<SecondaryButton onClick={() => setShowAnalysisModal(false)}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleStartAnalysis} startIcon={<AutoAwesomeIcon />}>
|
||||
Start Analysis
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha, Box } fro
|
||||
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
|
||||
import { Fact } from "./types";
|
||||
import { GlassyCard, glassyCardSx } from "./ui";
|
||||
import { TextToSpeechButton } from "../shared/TextToSpeechButton";
|
||||
|
||||
interface FactCardProps {
|
||||
fact: Fact;
|
||||
@@ -162,6 +163,7 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<TextToSpeechButton text={fact.quote} size="small" />
|
||||
</Stack>
|
||||
|
||||
{/* Confidence and Date */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -26,6 +26,8 @@ interface PodcastBiblePanelProps {
|
||||
}
|
||||
|
||||
export const PodcastBiblePanel: React.FC<PodcastBiblePanelProps> = ({ bible, onUpdate }) => {
|
||||
const [panelExpanded, setPanelExpanded] = useState(false);
|
||||
|
||||
if (!bible) return null;
|
||||
|
||||
const handleUpdateHost = (field: string, value: any) => {
|
||||
@@ -51,136 +53,157 @@ export const PodcastBiblePanel: React.FC<PodcastBiblePanelProps> = ({ bible, onU
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
|
||||
<AutoFixHighIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight="bold" color="#1e293b">
|
||||
Podcast Bible
|
||||
</Typography>
|
||||
<Tooltip title="Hyper-personalized context derived from your onboarding data. This grounds all research and script generation.">
|
||||
<IconButton size="small">
|
||||
<InfoIcon fontSize="small" sx={{ color: '#94a3b8' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Accordion
|
||||
expanded={panelExpanded}
|
||||
onChange={() => setPanelExpanded(!panelExpanded)}
|
||||
sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
bgcolor: panelExpanded ? 'rgba(99,102,241,0.05)' : 'transparent',
|
||||
borderRadius: panelExpanded ? '16px 16px 0 0' : 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ width: '100%' }}>
|
||||
<AutoFixHighIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight="bold" color="#1e293b" sx={{ flex: 1 }}>
|
||||
Podcast Bible
|
||||
</Typography>
|
||||
{!panelExpanded && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Host • Audience • Brand
|
||||
</Typography>
|
||||
)}
|
||||
<Tooltip title="Hyper-personalized context derived from your onboarding data. This grounds all research and script generation.">
|
||||
<IconButton size="small" onClick={(e) => e.stopPropagation()}>
|
||||
<InfoIcon fontSize="small" sx={{ color: '#94a3b8' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ bgcolor: 'rgba(99,102,241,0.02)' }}>
|
||||
<Stack spacing={2}>
|
||||
{/* Host Persona */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.08)', bgcolor: '#fff' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<PsychologyIcon sx={{ color: '#6366f1' }} />
|
||||
<Typography fontWeight="600">Host Persona</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Host Background"
|
||||
size="small"
|
||||
value={bible.host?.background || ''}
|
||||
onChange={(e) => handleUpdateHost('background', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Expertise Level"
|
||||
size="small"
|
||||
value={bible.host?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateHost('expertise_level', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Vocal Style"
|
||||
size="small"
|
||||
value={bible.host?.vocal_style || ''}
|
||||
onChange={(e) => handleUpdateHost('vocal_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{/* Host Persona */}
|
||||
<Accordion defaultExpanded sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<PsychologyIcon sx={{ color: '#6366f1' }} />
|
||||
<Typography fontWeight="600">Host Persona</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Host Background"
|
||||
size="small"
|
||||
value={bible.host?.background || ''}
|
||||
onChange={(e) => handleUpdateHost('background', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Expertise Level"
|
||||
size="small"
|
||||
value={bible.host?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateHost('expertise_level', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Vocal Style"
|
||||
size="small"
|
||||
value={bible.host?.vocal_style || ''}
|
||||
onChange={(e) => handleUpdateHost('vocal_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Audience DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<GroupsIcon sx={{ color: '#ec4899' }} />
|
||||
<Typography fontWeight="600">Audience DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Audience Expertise"
|
||||
size="small"
|
||||
value={bible.audience?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateAudience('expertise_level', e.target.value)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Interests
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.interests?.map((interest: string, idx: number) => (
|
||||
<Chip key={idx} label={interest} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Pain Points
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.pain_points?.map((point: string, idx: number) => (
|
||||
<Chip key={idx} label={point} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Audience DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<GroupsIcon sx={{ color: '#ec4899' }} />
|
||||
<Typography fontWeight="600">Audience DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Audience Expertise"
|
||||
size="small"
|
||||
value={bible.audience?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateAudience('expertise_level', e.target.value)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Interests
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.interests?.map((interest: string, idx: number) => (
|
||||
<Chip key={idx} label={interest} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Pain Points
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.pain_points?.map((point: string, idx: number) => (
|
||||
<Chip key={idx} label={point} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Brand DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<BrandIcon sx={{ color: '#10b981' }} />
|
||||
<Typography fontWeight="600">Brand DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Industry"
|
||||
size="small"
|
||||
value={bible.brand?.industry || ''}
|
||||
onChange={(e) => handleUpdateBrand('industry', e.target.value)}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Tone"
|
||||
size="small"
|
||||
value={bible.brand?.tone || ''}
|
||||
onChange={(e) => handleUpdateBrand('tone', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Style"
|
||||
size="small"
|
||||
value={bible.brand?.communication_style || ''}
|
||||
onChange={(e) => handleUpdateBrand('communication_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
{/* Brand DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<BrandIcon sx={{ color: '#10b981' }} />
|
||||
<Typography fontWeight="600">Brand DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Industry"
|
||||
size="small"
|
||||
value={bible.brand?.industry || ''}
|
||||
onChange={(e) => handleUpdateBrand('industry', e.target.value)}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Tone"
|
||||
size="small"
|
||||
value={bible.brand?.tone || ''}
|
||||
onChange={(e) => handleUpdateBrand('tone', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Style"
|
||||
size="small"
|
||||
value={bible.brand?.communication_style || ''}
|
||||
onChange={(e) => handleUpdateBrand('communication_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@mui/material";
|
||||
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
|
||||
import { CreateModal } from "./CreateModal";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
@@ -78,7 +78,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
}, [resetState]);
|
||||
|
||||
if (showProjectList) {
|
||||
return <ProjectList onSelectProject={handleSelectProject} />;
|
||||
return <ProjectList onSelectProject={handleSelectProject} onBack={() => setShowProjectList(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -197,19 +197,13 @@ const PodcastDashboard: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(workflow.isAnalyzing || workflow.isResearching) && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<CircularProgress size={20} />}
|
||||
sx={{
|
||||
background: "#fef3c7",
|
||||
border: "1px solid #fde68a",
|
||||
}}
|
||||
>
|
||||
<Box component="span" sx={{ fontSize: "0.875rem" }}>
|
||||
{workflow.isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
|
||||
</Box>
|
||||
</Alert>
|
||||
{(workflow.isAnalyzing || workflow.isResearching || workflow.isGeneratingScript) && (
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ py: 1.5 }}>
|
||||
<CircularProgress size={20} sx={{ color: "#667eea" }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
{workflow.isAnalyzing ? "Analyzing your idea with AI..." : workflow.isGeneratingScript ? "Generating script with AI..." : "Running research... This may take a moment."}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
@@ -238,6 +232,11 @@ const PodcastDashboard: React.FC = () => {
|
||||
avatarPrompt={project?.avatarPrompt}
|
||||
onRegenerate={() => setShowRegenModal(true)}
|
||||
onUpdateAnalysis={(updated) => projectState.setAnalysis(updated)}
|
||||
onRunResearch={() => workflow.handleRunResearch()}
|
||||
isResearchRunning={workflow.isResearching}
|
||||
selectedQueries={selectedQueries}
|
||||
onToggleQuery={workflow.toggleQuery}
|
||||
queries={queries}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -251,6 +250,11 @@ const PodcastDashboard: React.FC = () => {
|
||||
onToggleQuery={workflow.toggleQuery}
|
||||
onProviderChange={setResearchProvider}
|
||||
onRunResearch={workflow.handleRunResearch}
|
||||
onRegenerateQueries={workflow.handleRegenerateQueries}
|
||||
onUpdateQuery={workflow.handleUpdateQuery}
|
||||
onDeleteQuery={workflow.handleDeleteQuery}
|
||||
analysis={analysis}
|
||||
idea={project?.idea || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -259,6 +263,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
research={research}
|
||||
canGenerateScript={workflow.canGenerateScript}
|
||||
onGenerateScript={workflow.handleGenerateScript}
|
||||
isGeneratingScript={workflow.isGeneratingScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -332,6 +337,55 @@ const PodcastDashboard: React.FC = () => {
|
||||
}}
|
||||
isSubmitting={workflow.isAnalyzing}
|
||||
/>
|
||||
|
||||
{/* Duplicate Project Dialog */}
|
||||
<Dialog
|
||||
open={workflow.showDuplicateDialog}
|
||||
onClose={() => workflow.setShowDuplicateDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(167, 139, 250, 0.3)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
Duplicate Project Found
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: "rgba(255,255,255,0.8)" }}>
|
||||
<Alert severity="warning" sx={{ mb: 2, bgcolor: "rgba(245,158,11,0.1)", border: "1px solid rgba(245,158,11,0.3)" }}>
|
||||
A project with a similar idea already exists. You can edit the existing project or create a new one (which will overwrite the previous).
|
||||
</Alert>
|
||||
<Box sx={{ p: 2, bgcolor: "rgba(255,255,255,0.05)", borderRadius: 2 }}>
|
||||
<strong style={{ color: "#fff" }}>Existing project idea:</strong>
|
||||
<p style={{ color: "rgba(255,255,255,0.7)", marginTop: 8 }}>
|
||||
{workflow.duplicateProjectInfo.idea}
|
||||
</p>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
workflow.setShowDuplicateDialog(false);
|
||||
// Load existing project
|
||||
loadProjectFromDb(workflow.duplicateProjectInfo.projectId);
|
||||
}}
|
||||
sx={{ color: "#a78bfa" }}
|
||||
>
|
||||
Edit Existing
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => workflow.setShowDuplicateDialog(false)}
|
||||
variant="contained"
|
||||
sx={{ bgcolor: "#ef4444", "&:hover": { bgcolor: "#dc2626" } }}
|
||||
>
|
||||
Create New (Overwrite)
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
MenuItem,
|
||||
Box,
|
||||
alpha,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon } from "@mui/icons-material";
|
||||
import { ResearchProvider } from "../../../services/blogWriterApi";
|
||||
import { Query } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "../ui";
|
||||
|
||||
interface QuerySelectionProps {
|
||||
queries: Query[];
|
||||
@@ -30,6 +36,11 @@ interface QuerySelectionProps {
|
||||
onToggleQuery: (id: string) => void;
|
||||
onProviderChange: (provider: ResearchProvider) => void;
|
||||
onRunResearch: () => void;
|
||||
onRegenerateQueries: (feedback: string) => Promise<void>;
|
||||
onUpdateQuery: (id: string, newQuery: string, newRationale: string) => void;
|
||||
onDeleteQuery: (id: string) => void;
|
||||
analysis: any;
|
||||
idea: string;
|
||||
}
|
||||
|
||||
export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
@@ -40,9 +51,51 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
onToggleQuery,
|
||||
onProviderChange,
|
||||
onRunResearch,
|
||||
onRegenerateQueries,
|
||||
onUpdateQuery,
|
||||
onDeleteQuery,
|
||||
analysis,
|
||||
idea,
|
||||
}) => {
|
||||
const [showRegenDialog, setShowRegenDialog] = useState(false);
|
||||
const [regenFeedback, setRegenFeedback] = useState("");
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editQuery, setEditQuery] = useState("");
|
||||
const [editRationale, setEditRationale] = useState("");
|
||||
const selectedCount = selectedQueries.size;
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!regenFeedback.trim()) return;
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
await onRegenerateQueries(regenFeedback);
|
||||
setShowRegenDialog(false);
|
||||
setRegenFeedback("");
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (q: Query) => {
|
||||
setEditingId(q.id);
|
||||
setEditQuery(q.query);
|
||||
setEditRationale(q.rationale);
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingId && editQuery.trim()) {
|
||||
onUpdateQuery(editingId, editQuery.trim(), editRationale.trim());
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditQuery("");
|
||||
setEditRationale("");
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
@@ -55,10 +108,22 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
>
|
||||
<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" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<SearchIcon />
|
||||
Research Queries
|
||||
</Typography>
|
||||
<Tooltip title="Regenerate research queries with custom feedback">
|
||||
<PrimaryButton
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => setShowRegenDialog(true)}
|
||||
sx={{ py: 0.5, px: 1.5, fontSize: "0.75rem" }}
|
||||
>
|
||||
Regenerate
|
||||
</PrimaryButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
@@ -123,26 +188,70 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
|
||||
<List>
|
||||
{queries.map((q) => (
|
||||
<ListItem key={q.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => onToggleQuery(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
|
||||
key={q.id}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
editingId === q.id ? (
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<IconButton size="small" onClick={saveEdit} sx={{ color: "#22c55e" }}>
|
||||
<CheckCircleIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={cancelEdit} sx={{ color: "#ef4444" }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.5} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton size="small" onClick={() => startEdit(q)} sx={{ color: "#6366f1" }}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onDeleteQuery(q.id)} sx={{ color: "#ef4444" }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingId === q.id ? (
|
||||
<Box sx={{ width: "100%", p: 1.5, bgcolor: "#f0f9ff", borderRadius: 2, border: "1px solid #bae6fd" }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Query"
|
||||
value={editQuery}
|
||||
onChange={(e) => setEditQuery(e.target.value)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Rationale"
|
||||
value={editRationale}
|
||||
onChange={(e) => setEditRationale(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ListItemButton
|
||||
onClick={() => onToggleQuery(q.id)}
|
||||
disabled={isResearching}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: selectedQueries.has(q.id) ? alpha("#667eea", 0.08) : "#f8fafc",
|
||||
"&:hover": { background: alpha("#667eea", 0.12) },
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
@@ -163,6 +272,69 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
</PrimaryButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Regenerate Queries Dialog */}
|
||||
<Dialog
|
||||
open={showRegenDialog}
|
||||
onClose={() => setShowRegenDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(167, 139, 250, 0.3)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<RefreshIcon sx={{ color: "#a78bfa" }} />
|
||||
Regenerate Research Queries
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: "rgba(255,255,255,0.8)" }}>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)" }}>
|
||||
Provide custom directions to regenerate research queries. You can specify:
|
||||
</Typography>
|
||||
<Box sx={{ pl: 2, mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Specific topics or angles you want to explore
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Questions you want answered
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Areas where you need more depth
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="e.g., I want to focus more on competitive landscape and pricing strategies. Also need stats on market growth in 2025..."
|
||||
value={regenFeedback}
|
||||
onChange={(e) => setRegenFeedback(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "#fff",
|
||||
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
|
||||
"&.Mui-focused fieldset": { borderColor: "#a78bfa" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
<SecondaryButton onClick={() => setShowRegenDialog(false)}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleRegenerate}
|
||||
disabled={!regenFeedback.trim() || isRegenerating}
|
||||
loading={isRegenerating}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Generate New Queries
|
||||
</PrimaryButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper } from "@mui/material";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Stepper, Step, StepLabel, CircularProgress } from "@mui/material";
|
||||
import {
|
||||
Insights as InsightsIcon,
|
||||
Search as SearchIcon,
|
||||
@@ -7,21 +7,26 @@ import {
|
||||
EditNote as EditNoteIcon,
|
||||
Article as ArticleIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research, ResearchInsight } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { FactCard } from "../FactCard";
|
||||
import { TextToSpeechButton } from "../../shared/TextToSpeechButton";
|
||||
|
||||
interface ResearchSummaryProps {
|
||||
research: Research;
|
||||
canGenerateScript: boolean;
|
||||
onGenerateScript: () => void;
|
||||
isGeneratingScript?: boolean;
|
||||
}
|
||||
|
||||
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
research,
|
||||
canGenerateScript,
|
||||
onGenerateScript,
|
||||
isGeneratingScript = false,
|
||||
}) => {
|
||||
// Simple markdown-to-HTML converter
|
||||
const renderMarkdown = useCallback((text: string) => {
|
||||
@@ -51,6 +56,34 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
{/* Step Indicator */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Stepper activeStep={1} alternativeLabel>
|
||||
<Step completed>
|
||||
<StepLabel
|
||||
StepIconComponent={() => <CheckCircleIcon sx={{ color: "#22c55e", fontSize: 24 }} />}
|
||||
>
|
||||
Analysis
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step active>
|
||||
<StepLabel>
|
||||
Research
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Script
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Render
|
||||
</StepLabel>
|
||||
</Step>
|
||||
</Stepper>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
@@ -115,11 +148,31 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
|
||||
<PrimaryButton
|
||||
onClick={onGenerateScript}
|
||||
disabled={!canGenerateScript}
|
||||
startIcon={<EditNoteIcon />}
|
||||
disabled={!canGenerateScript || isGeneratingScript}
|
||||
startIcon={isGeneratingScript ? <CircularProgress size={18} color="inherit" /> : <EditNoteIcon />}
|
||||
endIcon={isGeneratingScript ? undefined : <ArrowForwardIcon />}
|
||||
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 4px 14px rgba(102, 126, 234, 0.4)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
boxShadow: "0 6px 20px rgba(102, 126, 234, 0.5)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "#94a3b8",
|
||||
boxShadow: "none",
|
||||
}
|
||||
}}
|
||||
>
|
||||
Generate Script
|
||||
{isGeneratingScript ? "Generating Script..." : "Generate Script to Continue"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
@@ -139,6 +192,9 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||
Executive Summary
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<TextToSpeechButton text={research.summary} size="small" showSettings />
|
||||
</Box>
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
lineHeight: 1.6,
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||
import { CreateProjectPayload, Script } from "../types";
|
||||
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
|
||||
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
|
||||
import { clearSceneMediaCache, clearMediaCache } from "../../../utils/mediaCache";
|
||||
|
||||
const createId = (prefix: string) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
|
||||
|
||||
@@ -41,18 +44,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
updateRenderJob,
|
||||
setRenderJobs,
|
||||
initializeProject,
|
||||
setBible,
|
||||
} = projectState;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [isGeneratingScript, setIsGeneratingScript] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [announcementSeverity, setAnnouncementSeverity] = useState<"info" | "error" | "success">("info");
|
||||
const [showResumeAlert, setShowResumeAlert] = useState(false);
|
||||
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
|
||||
const [preflightResponse, setPreflightResponse] = useState<any>(null);
|
||||
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
|
||||
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
|
||||
const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" });
|
||||
|
||||
const budgetTracking = useBudgetTracking(budgetCap || 50);
|
||||
const preflightCheck = usePreflightCheck({
|
||||
@@ -113,7 +120,27 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
// This allows the analysis to be personalized using the Bible context
|
||||
const projectId = project?.id || `podcast_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
setAnnouncement("Initializing project and brand context...");
|
||||
const dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
throw initError;
|
||||
}
|
||||
|
||||
const bible = dbProject?.bible || projectState.bible;
|
||||
|
||||
setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming");
|
||||
@@ -131,7 +158,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
analysis: result.analysis,
|
||||
estimate: result.estimate,
|
||||
queries: result.queries,
|
||||
selected_queries: result.queries.map(q => q.id),
|
||||
selected_queries: [], // Don't auto-select - user must choose manually
|
||||
avatar_url: result.avatar_url,
|
||||
avatar_prompt: result.avatar_prompt,
|
||||
});
|
||||
@@ -152,7 +179,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||
setSelectedQueries(new Set()); // Start with none selected - user must choose manually
|
||||
setKnobs(payload.knobs);
|
||||
setBudgetCap(payload.budgetCap);
|
||||
|
||||
@@ -192,6 +219,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnnouncement("Analysis complete");
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle duplicate idea error
|
||||
const errorMessage = error?.message || String(error);
|
||||
if (errorMessage.startsWith("DUPLICATE_IDEA:")) {
|
||||
const parts = errorMessage.split(":");
|
||||
const existingId = parts[1] || "";
|
||||
const existingIdea = parts.slice(2).join(":") || "existing project";
|
||||
setAnnouncement("");
|
||||
setShowDuplicateDialog(true);
|
||||
setDuplicateProjectInfo({ projectId: existingId, idea: existingIdea });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error?.response?.status === 429 || error?.response?.data?.detail) {
|
||||
const errorDetail = error.response.data.detail;
|
||||
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
|
||||
@@ -240,6 +279,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
|
||||
setPreflightOperationName("Research");
|
||||
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
|
||||
console.log('[Research] User selected queries:', Array.from(selectedQueries));
|
||||
console.log('[Research] Filtered approvedQueries for API:', approvedQueries.map(q => q.query));
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: researchProvider === "exa" ? "exa" : "gemini",
|
||||
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
|
||||
@@ -261,6 +302,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setShowRenderQueue(false);
|
||||
|
||||
try {
|
||||
console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider });
|
||||
console.log('[Research] Calling podcastApi.runResearch...');
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
topic: project.idea,
|
||||
@@ -273,6 +316,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
console.log('[Research] Response received:', { mapped, raw });
|
||||
setResearch(mapped);
|
||||
setRawResearch(raw);
|
||||
setAnnouncement("Research complete — review fact cards below");
|
||||
@@ -281,6 +325,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
? researchError.message
|
||||
: "Research failed. Please try again or switch to Standard Research.";
|
||||
|
||||
console.error('[Research] Error caught:', researchError);
|
||||
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
|
||||
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
|
||||
} else if (errorMessage.includes("timeout")) {
|
||||
@@ -321,8 +366,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
setIsGeneratingScript(true);
|
||||
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
|
||||
|
||||
try {
|
||||
console.log('[ScriptGen] Starting script generation with:', {
|
||||
idea: project.idea,
|
||||
speakers: project.speakers,
|
||||
duration: project.duration,
|
||||
hasResearch: !!rawResearch,
|
||||
hasOutline: !!analysis?.suggestedOutlines?.[0],
|
||||
});
|
||||
|
||||
const result = await podcastApi.generateScript({
|
||||
projectId: project.id,
|
||||
idea: project.idea,
|
||||
@@ -331,35 +386,55 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
speakers: project.speakers,
|
||||
durationMinutes: project.duration,
|
||||
bible: projectState.bible,
|
||||
outline: analysis?.suggestedOutlines?.[0], // Pass the first (possibly refined) outline
|
||||
analysis: analysis, // Pass full analysis context
|
||||
outline: analysis?.suggestedOutlines?.[0],
|
||||
analysis: analysis,
|
||||
onProgress: (message) => {
|
||||
console.log('[ScriptGen] Progress:', message);
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length });
|
||||
setScriptData(result);
|
||||
setIsGeneratingScript(false);
|
||||
setAnnouncement("Script generated! Review and edit your scenes below.");
|
||||
} catch (error) {
|
||||
setIsGeneratingScript(false);
|
||||
announceError(setAnnouncement, setAnnouncementSeverityFn, error);
|
||||
}
|
||||
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible])
|
||||
|
||||
const handleProceedToRendering = useCallback((script: Script) => {
|
||||
// Clear media cache for all scenes before proceeding to remove old blobs
|
||||
script.scenes.forEach((scene) => {
|
||||
clearSceneMediaCache(scene.id);
|
||||
});
|
||||
// Also clear global media cache to ensure clean slate
|
||||
clearMediaCache();
|
||||
|
||||
// Clear all render jobs to start fresh (removes old videos/images)
|
||||
setRenderJobs([]);
|
||||
|
||||
setScriptData(script);
|
||||
if (renderJobs.length === 0) {
|
||||
script.scenes.forEach((scene) => {
|
||||
const hasExistingAudio = Boolean(scene.audioUrl);
|
||||
updateRenderJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? scene.audioUrl : null,
|
||||
jobId: null,
|
||||
});
|
||||
// Create new render jobs with current script scene data
|
||||
script.scenes.forEach((scene) => {
|
||||
const hasExistingAudio = Boolean(scene.audioUrl);
|
||||
const hasExistingImage = Boolean(scene.imageUrl);
|
||||
updateRenderJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? scene.audioUrl : null,
|
||||
imageUrl: hasExistingImage ? scene.imageUrl : null,
|
||||
videoUrl: null,
|
||||
jobId: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
setShowRenderQueue(true);
|
||||
setShowScriptEditor(false);
|
||||
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
}, [setScriptData, setRenderJobs, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
const toggleQuery = useCallback((id: string) => {
|
||||
if (isResearching) return;
|
||||
@@ -370,6 +445,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setSelectedQueries(next);
|
||||
}, [isResearching, selectedQueries, setSelectedQueries]);
|
||||
|
||||
const handleUpdateQuery = useCallback((id: string, newQuery: string, newRationale: string) => {
|
||||
const updated = queries.map(q => q.id === id ? { ...q, query: newQuery, rationale: newRationale } : q);
|
||||
setQueries(updated);
|
||||
}, [queries, setQueries]);
|
||||
|
||||
const handleDeleteQuery = useCallback((id: string) => {
|
||||
const updated = queries.filter(q => q.id !== id);
|
||||
setQueries(updated);
|
||||
// Also remove from selected if it was selected
|
||||
if (selectedQueries.has(id)) {
|
||||
const newSelected = new Set(selectedQueries);
|
||||
newSelected.delete(id);
|
||||
setSelectedQueries(newSelected);
|
||||
}
|
||||
}, [queries, selectedQueries, setQueries, setSelectedQueries]);
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
if (showRenderQueue) return 3;
|
||||
if (showScriptEditor) return 2;
|
||||
@@ -397,6 +488,37 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
await handleCreate(payload, feedback);
|
||||
}, [project, projectState.knobs, projectState.budgetCap, handleCreate]);
|
||||
|
||||
// Regenerate only research queries (keeps other sections intact)
|
||||
const handleRegenerateQueries = useCallback(async (feedback: string) => {
|
||||
if (!project || !analysis) return;
|
||||
|
||||
setAnnouncement("Regenerating research queries...");
|
||||
|
||||
try {
|
||||
const response = await podcastApi.regenerateResearchQueries({
|
||||
idea: project.idea,
|
||||
feedback: feedback,
|
||||
existing_analysis: analysis,
|
||||
bible: projectState.bible,
|
||||
});
|
||||
|
||||
// Convert to Query format
|
||||
const newQueries = response.research_queries.map((rq, idx) => ({
|
||||
id: createId("q"),
|
||||
query: rq.query,
|
||||
rationale: rq.rationale,
|
||||
needsRecentStats: /202[45]|latest|trend/i.test(rq.query),
|
||||
}));
|
||||
|
||||
setQueries(newQueries);
|
||||
setSelectedQueries(new Set()); // Don't auto-select - user must choose manually
|
||||
setAnnouncement("Research queries regenerated");
|
||||
} catch (error) {
|
||||
console.error("Failed to regenerate queries:", error);
|
||||
setAnnouncement("Failed to regenerate queries");
|
||||
}
|
||||
}, [project, analysis, projectState.bible, setQueries, setSelectedQueries]);
|
||||
|
||||
const setAnnouncementSeverityFn = useCallback((severity: "info" | "error" | "success") => {
|
||||
setAnnouncementSeverity(severity);
|
||||
}, []);
|
||||
@@ -405,12 +527,15 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
// State
|
||||
isAnalyzing,
|
||||
isResearching,
|
||||
isGeneratingScript,
|
||||
announcement,
|
||||
announcementSeverity,
|
||||
showResumeAlert,
|
||||
showPreflightDialog,
|
||||
preflightResponse,
|
||||
preflightOperationName,
|
||||
showDuplicateDialog,
|
||||
duplicateProjectInfo,
|
||||
activeStep,
|
||||
canGenerateScript,
|
||||
// Handlers
|
||||
@@ -425,8 +550,13 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setShowResumeAlert,
|
||||
setShowPreflightDialog,
|
||||
setPreflightResponse,
|
||||
setShowDuplicateDialog,
|
||||
setDuplicateProjectInfo,
|
||||
setResearchProvider,
|
||||
getStepLabel,
|
||||
handleRegenerateQueries: handleRegenerateQueries,
|
||||
handleUpdateQuery,
|
||||
handleDeleteQuery,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { CreateProjectPayload, Knobs } from "../types";
|
||||
export const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
voice_id: "Wise_Woman",
|
||||
custom_voice_id: undefined,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
|
||||
@@ -22,10 +22,12 @@ import {
|
||||
Mic as MicIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Star as StarIcon,
|
||||
StarBorder as StarBorderIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Search as SearchIcon,
|
||||
ArrowBack as ArrowBackIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
|
||||
@@ -45,9 +47,10 @@ interface Project {
|
||||
|
||||
interface ProjectListProps {
|
||||
onSelectProject: (projectId: string) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) => {
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject, onBack }) => {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -175,6 +178,9 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<SecondaryButton onClick={onBack || (() => navigate(-1))} startIcon={<ArrowBackIcon />}>
|
||||
Back
|
||||
</SecondaryButton>
|
||||
<SecondaryButton onClick={loadProjects} startIcon={<RefreshIcon />} disabled={loading}>
|
||||
Refresh
|
||||
</SecondaryButton>
|
||||
@@ -248,7 +254,7 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box flex={1}>
|
||||
<Box flex={1} onClick={() => onSelectProject(project.project_id)} sx={{ cursor: "pointer" }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{project.idea.length > 100 ? `${project.idea.substring(0, 100)}...` : project.idea}
|
||||
</Typography>
|
||||
@@ -270,14 +276,25 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="Edit project">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectProject(project.project_id);
|
||||
}}
|
||||
sx={{ color: "#a78bfa" }}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={project.is_favorite ? "Remove from favorites" : "Add to favorites"}>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(project.project_id, project.is_favorite);
|
||||
}}
|
||||
sx={{ color: project.is_favorite ? "#fbbf24" : "rgba(255,255,255,0.5)" }}
|
||||
sx={{ color: project.is_favorite ? "#fbbf24" : "#a78bfa" }}
|
||||
>
|
||||
{project.is_favorite ? <StarIcon /> : <StarBorderIcon />}
|
||||
</IconButton>
|
||||
@@ -289,7 +306,7 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
setProjectToDelete(project.project_id);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
sx={{ color: "rgba(255,255,255,0.5)" }}
|
||||
sx={{ color: "#ef4444" }}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -63,6 +63,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
knobs,
|
||||
projectId,
|
||||
bible,
|
||||
analysis,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
|
||||
@@ -46,33 +46,39 @@ export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
|
||||
// Use a more intelligent default prompt based on context if available
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
|
||||
// Update prompt when context changes or modal opens
|
||||
// Update prompt when modal opens - build enhanced prompt from context
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
let smartPrompt = initialPrompt;
|
||||
// Always build an enhanced prompt from available context
|
||||
const parts = [];
|
||||
|
||||
// If the initial prompt is generic/empty, try to build a better one
|
||||
if (!smartPrompt || smartPrompt === "Professional podcast scene with subtle movement") {
|
||||
const parts = [];
|
||||
|
||||
// Add scene context
|
||||
if (sceneTitle) parts.push(`Scene: ${sceneTitle}`);
|
||||
|
||||
// Add bible/persona context
|
||||
if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`);
|
||||
if (bible?.tone) parts.push(`Tone: ${bible.tone}`);
|
||||
|
||||
// Add analysis context
|
||||
if (analysis?.content_type) parts.push(`Style: ${analysis.content_type}`);
|
||||
|
||||
// Combine into a descriptive prompt
|
||||
if (parts.length > 0) {
|
||||
smartPrompt = `Professional talking head video for podcast. ${parts.join(". ")}. Cinematic lighting, 4k, high detail.`;
|
||||
}
|
||||
// Add scene context
|
||||
if (sceneTitle) parts.push(`Scene: ${sceneTitle}`);
|
||||
|
||||
// Add bible/persona context
|
||||
if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`);
|
||||
if (bible?.tone) parts.push(`Tone: ${bible.tone}`);
|
||||
if (bible?.visual_style) parts.push(`Visual Style: ${bible.visual_style}`);
|
||||
if (bible?.background) parts.push(`Background: ${bible.background}`);
|
||||
|
||||
// Add analysis context
|
||||
if (analysis?.content_type) parts.push(`Content Type: ${analysis.content_type}`);
|
||||
if (analysis?.audience) parts.push(`Target: ${analysis.audience}`);
|
||||
if (analysis?.guestName) parts.push(`Guest: ${analysis.guestName}`);
|
||||
if (analysis?.keyTakeaways?.length) parts.push(`Key: ${analysis.keyTakeaways[0]}`);
|
||||
|
||||
// Build enhanced prompt
|
||||
let smartPrompt = "";
|
||||
if (parts.length > 0) {
|
||||
smartPrompt = `Professional podcast video. ${parts.join(". ")}. Cinematic lighting, high detail, 4k quality, smooth subtle motion.`;
|
||||
} else {
|
||||
// Fallback to initial prompt
|
||||
smartPrompt = initialPrompt || "Professional podcast scene with subtle movement";
|
||||
}
|
||||
|
||||
setPrompt(smartPrompt);
|
||||
}
|
||||
}, [open, initialPrompt, sceneTitle, bible, analysis]);
|
||||
}, [open, sceneTitle, bible, analysis]);
|
||||
|
||||
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
|
||||
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
|
||||
|
||||
@@ -8,6 +8,7 @@ interface UseRenderQueueProps {
|
||||
knobs: Knobs;
|
||||
projectId: string;
|
||||
bible?: any | null;
|
||||
analysis?: any | null;
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
|
||||
@@ -23,6 +24,7 @@ export const useRenderQueue = ({
|
||||
knobs,
|
||||
projectId,
|
||||
bible,
|
||||
analysis,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
@@ -54,27 +56,32 @@ export const useRenderQueue = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize jobs if empty (audio/image only)
|
||||
// Initialize jobs if empty (audio/image only) OR sync with script scenes
|
||||
useEffect(() => {
|
||||
if (jobs.length === 0 && script.scenes.length > 0) {
|
||||
const initialJobs: Job[] = script.scenes.map((s) => {
|
||||
// Always sync jobs with script scenes - this ensures render queue shows current audio/image
|
||||
if (script.scenes.length > 0) {
|
||||
script.scenes.forEach((s) => {
|
||||
const hasExistingAudio = Boolean(s.audioUrl);
|
||||
return {
|
||||
const hasExistingImage = Boolean(s.imageUrl);
|
||||
const isReady = hasExistingAudio;
|
||||
|
||||
// Create job from scene data
|
||||
const jobFromScene: Job = {
|
||||
sceneId: s.id,
|
||||
title: s.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
status: isReady ? ("completed" as const) : ("idle" as const),
|
||||
progress: isReady ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? s.audioUrl || null : null,
|
||||
imageUrl: s.imageUrl || null,
|
||||
imageUrl: hasExistingImage ? s.imageUrl || null : null,
|
||||
jobId: null,
|
||||
};
|
||||
});
|
||||
initialJobs.forEach((job) => {
|
||||
onUpdateJob(job.sceneId, job);
|
||||
|
||||
// Update job with scene's audio/image data
|
||||
onUpdateJob(s.id, jobFromScene);
|
||||
});
|
||||
}
|
||||
}, [jobs.length, script.scenes.length, onUpdateJob, script.scenes]);
|
||||
}, [script.scenes, onUpdateJob]);
|
||||
|
||||
// Load final video URL from project on mount (for persistence across reloads)
|
||||
useEffect(() => {
|
||||
@@ -95,6 +102,7 @@ export const useRenderQueue = ({
|
||||
}, [projectId]);
|
||||
|
||||
// Always try to attach existing videos to scenes (even after reloads)
|
||||
// But skip if job already has imageUrl - indicates user just came from script phase
|
||||
useEffect(() => {
|
||||
if (script.scenes.length === 0) return;
|
||||
|
||||
@@ -122,6 +130,23 @@ export const useRenderQueue = ({
|
||||
|
||||
const job = jobs.find((j) => j.sceneId === scene.id);
|
||||
|
||||
// Skip if job already has imageUrl from script phase - don't override with old video
|
||||
if (job?.imageUrl) {
|
||||
console.log("[useRenderQueue] Skipping old video - job has imageUrl from script phase:", scene.id, "imageUrl:", job.imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Job has no imageUrl - this could be from page reload or old state
|
||||
console.log("[useRenderQueue] Job missing imageUrl, checking for old video:", scene.id, "job:", job);
|
||||
|
||||
// Only attach old video if job has NO content at all (no image, no video, no audio)
|
||||
// If job has finalUrl (audio) or imageUrl from script phase, don't attach old video
|
||||
const isJobEmpty = !job || (!job.imageUrl && !job.videoUrl && !job.finalUrl);
|
||||
if (!isJobEmpty) {
|
||||
console.log("[useRenderQueue] Skipping old video - job has content already:", scene.id, "job:", job);
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid redundant updates
|
||||
if (job?.videoUrl === videoUrl) return;
|
||||
|
||||
@@ -569,6 +594,9 @@ export const useRenderQueue = ({
|
||||
audioUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
bible: bible,
|
||||
analysis: analysis, // Pass analysis for enhanced prompt
|
||||
sceneImagePrompt: scene.imagePrompt || undefined, // Original image generation prompt
|
||||
sceneNarration: scene.lines?.map((l: any) => l.text).join(" ").slice(0, 200) || undefined,
|
||||
resolution: targetResolution,
|
||||
prompt: settings?.prompt || undefined,
|
||||
seed: settings?.seed ?? -1,
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
} from "@mui/material";
|
||||
import { HelpOutline as HelpOutlineIcon, Close as CloseIcon } from "@mui/icons-material";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
import { VoiceSelector } from "../../shared/VoiceSelector";
|
||||
|
||||
export type AudioGenerationSettings = {
|
||||
voiceId: string;
|
||||
customVoiceId?: string;
|
||||
speed: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
@@ -156,26 +158,12 @@ export const AudioRegenerateModal: React.FC<AudioRegenerateModalProps> = ({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={settings.voiceId}
|
||||
onChange={(e) => setSettings({ ...settings, voiceId: e.target.value })}
|
||||
sx={{
|
||||
backgroundColor: alpha("#ffffff", 0.05),
|
||||
color: "white",
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.3)" },
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": { borderColor: "#667eea" },
|
||||
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
|
||||
}}
|
||||
>
|
||||
{VOICE_OPTIONS.map((v) => (
|
||||
<MenuItem key={v} value={v}>
|
||||
{v}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<VoiceSelector
|
||||
value={settings.voiceId}
|
||||
onChange={(voiceId) => setSettings({ ...settings, voiceId })}
|
||||
showVoiceClone={true}
|
||||
disabled={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Speed / Volume / Pitch */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip } from "@mui/material";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip, Dialog, DialogContent } from "@mui/material";
|
||||
import {
|
||||
EditNote as EditNoteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Image as ImageIcon,
|
||||
Delete as DeleteIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
Close as CloseIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Line, Knobs } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
@@ -31,6 +33,11 @@ interface SceneEditorProps {
|
||||
idea?: string; // Podcast idea for image generation context
|
||||
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||
totalScenes?: number; // Total number of scenes in the script
|
||||
analysis?: {
|
||||
audience?: string;
|
||||
contentType?: string;
|
||||
topKeywords?: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
@@ -46,6 +53,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
idea,
|
||||
avatarUrl,
|
||||
totalScenes,
|
||||
analysis,
|
||||
}) => {
|
||||
const [localGenerating, setLocalGenerating] = useState(false);
|
||||
const [generatingImage, setGeneratingImage] = useState(false);
|
||||
@@ -56,8 +64,10 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
||||
voiceId: "Wise_Woman",
|
||||
customVoiceId: undefined,
|
||||
speed: 1.0,
|
||||
volume: 1.0,
|
||||
pitch: 0.0,
|
||||
@@ -300,7 +310,8 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
const effectiveSettings = settings || audioSettings;
|
||||
const result = await podcastApi.renderSceneAudio({
|
||||
scene: currentScene,
|
||||
voiceId: effectiveSettings.voiceId || "Wise_Woman",
|
||||
voiceId: effectiveSettings.voiceId || knobs.voice_id || "Wise_Woman",
|
||||
customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id,
|
||||
emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
|
||||
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
|
||||
volume: effectiveSettings.volume ?? 1.0,
|
||||
@@ -323,6 +334,24 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to approve and generate audio:", error);
|
||||
|
||||
// Provide user-friendly error message based on error type
|
||||
let userMessage = "Failed to generate audio. Please try again.";
|
||||
|
||||
if (error instanceof Error) {
|
||||
const errorMsg = error.message.toLowerCase();
|
||||
|
||||
if (errorMsg.includes("429") || errorMsg.includes("quota") || errorMsg.includes("limit")) {
|
||||
userMessage = "Audio generation limit reached. Please check your subscription and try again.";
|
||||
} else if (errorMsg.includes("voice") || errorMsg.includes("custom_voice")) {
|
||||
userMessage = "Invalid voice. Please select a different voice and try again.";
|
||||
} else if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
|
||||
userMessage = "Audio generation timed out. Please try again.";
|
||||
} else if (errorMsg.includes("network") || errorMsg.includes("connection")) {
|
||||
userMessage = "Network error. Please check your connection and try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// On error, revert approval only if we just approved it in this call
|
||||
if (!wasAlreadyApproved) {
|
||||
onUpdateScene({ ...scene, approved: false, audioUrl: undefined });
|
||||
@@ -379,11 +408,12 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
sceneContent: sceneContent,
|
||||
baseAvatarUrl: avatarUrl || undefined, // Pass base avatar URL for character consistency
|
||||
sceneEmotion: scene.emotion,
|
||||
baseAvatarUrl: avatarUrl || undefined,
|
||||
idea: idea,
|
||||
analysis: analysis || undefined,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
// Pass custom settings if provided
|
||||
customPrompt: settings?.prompt,
|
||||
style: settings?.style,
|
||||
renderingSpeed: settings?.renderingSpeed,
|
||||
@@ -398,8 +428,12 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
setImageGenerationStatus("Finalizing image...");
|
||||
setImageGenerationProgress(95);
|
||||
|
||||
// Update scene with image URL
|
||||
const updatedScene = { ...scene, imageUrl: result.image_url };
|
||||
// Update scene with image URL and the prompt used
|
||||
const updatedScene = {
|
||||
...scene,
|
||||
imageUrl: result.image_url,
|
||||
imagePrompt: result.image_prompt || undefined,
|
||||
};
|
||||
onUpdateScene(updatedScene);
|
||||
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
@@ -725,11 +759,25 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5, width: "100%" }}>
|
||||
<ImageIcon sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontSize: "1.25rem" }} />
|
||||
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600, flex: 1 }}>
|
||||
{imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."}
|
||||
</Typography>
|
||||
{imageBlobUrl && !imageLoading && (
|
||||
<Tooltip title="View full size">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowImagePreview(true)}
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
"&:hover": { background: "rgba(102, 126, 234, 0.1)" },
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{imageBlobUrl && !imageLoading ? (
|
||||
<Box
|
||||
@@ -805,6 +853,49 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
initialSettings={audioSettings}
|
||||
isGenerating={generating}
|
||||
/>
|
||||
|
||||
{/* Full-size Image Preview Modal */}
|
||||
<Dialog
|
||||
open={showImagePreview}
|
||||
onClose={() => setShowImagePreview(false)}
|
||||
maxWidth="lg"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "rgba(0, 0, 0, 0.9)",
|
||||
borderRadius: 3,
|
||||
maxHeight: "90vh",
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, position: "relative" }}>
|
||||
<IconButton
|
||||
onClick={() => setShowImagePreview(false)}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
color: "#fff",
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
zIndex: 1,
|
||||
"&:hover": { background: "rgba(0, 0, 0, 0.7)" },
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageBlobUrl || ""}
|
||||
alt={scene.title}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
maxHeight: "85vh",
|
||||
objectFit: "contain",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
|
||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||
url: string;
|
||||
@@ -622,6 +622,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
}}
|
||||
idea={idea}
|
||||
avatarUrl={avatarUrl}
|
||||
analysis={analysis}
|
||||
/>
|
||||
</GlassyCard>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type Knobs = {
|
||||
voice_emotion: string;
|
||||
voice_speed: number;
|
||||
voice_id: string;
|
||||
custom_voice_id?: string;
|
||||
resolution: string;
|
||||
scene_length_target: number;
|
||||
sample_rate: number;
|
||||
@@ -64,6 +66,7 @@ export type Scene = {
|
||||
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
|
||||
};
|
||||
|
||||
export type Script = {
|
||||
@@ -104,6 +107,10 @@ export type PodcastAnalysis = {
|
||||
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
|
||||
suggestedKnobs: Knobs;
|
||||
titleSuggestions: string[];
|
||||
episode_hook?: string;
|
||||
key_takeaways?: string[];
|
||||
guest_talking_points?: string[];
|
||||
listener_cta?: string;
|
||||
research_queries?: { query: string; rationale: string }[];
|
||||
exaSuggestedConfig?: {
|
||||
exa_search_type?: "auto" | "keyword" | "neural";
|
||||
|
||||
@@ -7,9 +7,11 @@ interface PrimaryButtonProps {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
endIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
@@ -18,24 +20,32 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
startIcon,
|
||||
endIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
sx,
|
||||
size = "medium",
|
||||
}) => {
|
||||
const sizeStyles = {
|
||||
small: { px: 1.5, py: 0.5, fontSize: "0.75rem" },
|
||||
medium: { px: 3, py: 1, fontSize: "0.875rem" },
|
||||
large: { px: 4, py: 1.5, fontSize: "1rem" },
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
|
||||
endIcon={loading ? undefined : endIcon}
|
||||
aria-label={ariaLabel}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
px: 3,
|
||||
py: 1,
|
||||
...sizeStyles[size],
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
|
||||
169
frontend/src/components/shared/TextToSpeechButton.tsx
Normal file
169
frontend/src/components/shared/TextToSpeechButton.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { IconButton, Tooltip, CircularProgress, Box, Menu, MenuItem, ListItemIcon, ListItemText, FormControl, Select, Slider, Typography } from '@mui/material';
|
||||
import { VolumeUp as VolumeUpIcon, Stop as StopIcon, PlayArrow as PlayArrowIcon, Settings as SettingsIcon } from '@mui/icons-material';
|
||||
import { useTextToSpeech, SpeechSynthesisOptions } from '../../hooks/useTextToSpeech';
|
||||
|
||||
interface TextToSpeechButtonProps {
|
||||
text: string;
|
||||
textToSpeak?: string; // Optional different text to speak (e.g., shorter version)
|
||||
options?: SpeechSynthesisOptions;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showSettings?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TextToSpeechButton: React.FC<TextToSpeechButtonProps> = ({
|
||||
text,
|
||||
textToSpeak,
|
||||
options,
|
||||
size = 'medium',
|
||||
showSettings = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { speak, stop, isSpeaking, isSupported, voices, pause, resume, isPaused } = useTextToSpeech();
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const [selectedVoice, setSelectedVoice] = React.useState<SpeechSynthesisVoice | null>(null);
|
||||
const [rate, setRate] = React.useState(1);
|
||||
const [pitch, setPitch] = React.useState(1);
|
||||
const [volume, setVolume] = React.useState(1);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (showSettings) {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSpeak = () => {
|
||||
const textToUse = textToSpeak || text;
|
||||
if (!textToUse.trim()) return;
|
||||
|
||||
if (isSpeaking) {
|
||||
stop();
|
||||
} else {
|
||||
speak(textToUse, {
|
||||
voice: selectedVoice || undefined,
|
||||
rate,
|
||||
pitch,
|
||||
volume,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSupported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconSize = size === 'small' ? 18 : size === 'medium' ? 24 : 30;
|
||||
const buttonSize = size === 'small' ? 'small' : size === 'medium' ? 'medium' : 'large';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Tooltip title={isSpeaking ? "Stop" : "Read aloud"}>
|
||||
<IconButton
|
||||
onClick={handleSpeak}
|
||||
size={buttonSize}
|
||||
disabled={disabled || !text.trim()}
|
||||
sx={{
|
||||
color: isSpeaking ? '#ef4444' : '#667eea',
|
||||
backgroundColor: isSpeaking ? 'rgba(239, 68, 68, 0.1)' : 'rgba(102, 126, 234, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: isSpeaking ? 'rgba(239, 68, 68, 0.2)' : 'rgba(102, 126, 234, 0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isSpeaking ? <StopIcon sx={{ fontSize: iconSize }} /> : <VolumeUpIcon sx={{ fontSize: iconSize }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{showSettings && (
|
||||
<>
|
||||
<Tooltip title="Voice settings">
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
size={buttonSize}
|
||||
sx={{ ml: 0.5, color: 'rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: iconSize * 0.75 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
PaperProps={{ sx: { p: 2, minWidth: 280 } }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Voice Settings
|
||||
</Typography>
|
||||
|
||||
{/* Voice Selection */}
|
||||
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Voice</Typography>
|
||||
<Select
|
||||
value={selectedVoice?.name || ''}
|
||||
onChange={(e) => {
|
||||
const voice = voices.find(v => v.name === e.target.value);
|
||||
setSelectedVoice(voice || null);
|
||||
}}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Default</em>
|
||||
</MenuItem>
|
||||
{voices.map((voice) => (
|
||||
<MenuItem key={voice.name} value={voice.name}>
|
||||
{voice.name.split(' ')[0]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Speed */}
|
||||
<Typography variant="caption">Speed: {rate}x</Typography>
|
||||
<Slider
|
||||
value={rate}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setRate(value as number)}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* Pitch */}
|
||||
<Typography variant="caption">Pitch: {pitch}</Typography>
|
||||
<Slider
|
||||
value={pitch}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setPitch(value as number)}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* Volume */}
|
||||
<Typography variant="caption">Volume: {Math.round(volume * 100)}%</Typography>
|
||||
<Slider
|
||||
value={volume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setVolume(value as number)}
|
||||
size="small"
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextToSpeechButton;
|
||||
321
frontend/src/components/shared/VoiceSelector.tsx
Normal file
321
frontend/src/components/shared/VoiceSelector.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Stack,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
alpha,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Mic,
|
||||
PlayArrow,
|
||||
Pause,
|
||||
CloudUpload,
|
||||
HelpOutline,
|
||||
AutoAwesome,
|
||||
CheckCircle,
|
||||
} from "@mui/icons-material";
|
||||
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
|
||||
|
||||
export type VoiceOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
personality?: string;
|
||||
isCustom?: boolean;
|
||||
previewUrl?: string;
|
||||
};
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string;
|
||||
onChange: (voiceId: string) => void;
|
||||
disabled?: boolean;
|
||||
showVoiceClone?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const PREDEFINED_VOICES: VoiceOption[] = [
|
||||
{ id: "Wise_Woman", name: "Wise Woman", personality: "Authoritative, trustworthy female voice - perfect for educational content" },
|
||||
{ id: "Friendly_Person", name: "Friendly Person", personality: "Warm, approachable voice - great for welcoming introductions" },
|
||||
{ id: "Inspirational_girl", name: "Inspirational Girl", personality: "Motivational, uplifting female voice - ideal for inspiration" },
|
||||
{ id: "Deep_Voice_Man", name: "Deep Voice Man", personality: "Powerful, commanding male voice - excellent for serious topics" },
|
||||
{ id: "Calm_Woman", name: "Calm Woman", personality: "Soothing, composed female voice - perfect for meditation or sensitive topics" },
|
||||
{ id: "Casual_Guy", name: "Casual Guy", personality: "Relaxed, conversational male voice - great for vlogs and tutorials" },
|
||||
{ id: "Lively_Girl", name: "Lively Girl", personality: "Energetic, enthusiastic female voice - ideal for exciting announcements" },
|
||||
{ id: "Patient_Man", name: "Patient Man", personality: "Gentle, understanding male voice - perfect for explanations" },
|
||||
{ id: "Young_Knight", name: "Young Knight", personality: "Brave, confident male voice - great for adventure and gaming" },
|
||||
{ id: "Determined_Man", name: "Determined Man", personality: "Strong, resolute male voice - excellent for motivational speeches" },
|
||||
{ id: "Lovely_Girl", name: "Lovely Girl", personality: "Sweet, charming female voice - ideal for storytelling" },
|
||||
{ id: "Decent_Boy", name: "Decent Boy", personality: "Honest, sincere male voice - perfect for testimonials" },
|
||||
{ id: "Imposing_Manner", name: "Imposing Manner", personality: "Formal, dignified male voice - great for corporate content" },
|
||||
{ id: "Elegant_Man", name: "Elegant Man", personality: "Refined, sophisticated male voice - ideal for luxury content" },
|
||||
{ id: "Abbess", name: "Abbess", personality: "Spiritual, serene female voice - perfect for meditation" },
|
||||
{ id: "Sweet_Girl_2", name: "Sweet Girl 2", personality: "Gentle, melodic female voice - excellent for children's content" },
|
||||
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations" },
|
||||
];
|
||||
|
||||
const VOICE_CLONE_ID = "MY_VOICE_CLONE";
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
showVoiceClone = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [voiceClone, setVoiceClone] = useState<VoiceCloneResponse | null>(null);
|
||||
const [loadingVoiceClone, setLoadingVoiceClone] = useState(false);
|
||||
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
|
||||
|
||||
const voiceOptions = useMemo(() => {
|
||||
const options: VoiceOption[] = [...PREDEFINED_VOICES];
|
||||
|
||||
if (showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id) {
|
||||
options.unshift({
|
||||
id: VOICE_CLONE_ID,
|
||||
name: voiceClone.voice_name || voiceClone.custom_voice_id || "My Voice Clone",
|
||||
personality: "Your own voice - cloned from audio sample",
|
||||
isCustom: true,
|
||||
previewUrl: voiceClone.preview_audio_url,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [showVoiceClone, voiceClone]);
|
||||
|
||||
const selectedVoice = useMemo(() => {
|
||||
if (value === VOICE_CLONE_ID && voiceClone?.success) {
|
||||
return voiceOptions.find(v => v.id === VOICE_CLONE_ID);
|
||||
}
|
||||
return voiceOptions.find(v => v.id === value) || voiceOptions[0];
|
||||
}, [value, voiceOptions, voiceClone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVoiceClone) return;
|
||||
|
||||
const fetchVoiceClone = async () => {
|
||||
try {
|
||||
setLoadingVoiceClone(true);
|
||||
const result = await getLatestVoiceClone();
|
||||
setVoiceClone(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch voice clone:", error);
|
||||
} finally {
|
||||
setLoadingVoiceClone(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVoiceClone();
|
||||
}, [showVoiceClone]);
|
||||
|
||||
const handlePreview = (voice: VoiceOption) => {
|
||||
if (!voice.previewUrl) return;
|
||||
|
||||
if (playingPreview === voice.id) {
|
||||
const audio = document.getElementById(`voice-preview-${voice.id}`) as HTMLAudioElement;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
} else {
|
||||
setPlayingPreview(voice.id);
|
||||
const audio = new Audio(voice.previewUrl);
|
||||
audio.id = `voice-preview-${voice.id}`;
|
||||
audio.onerror = () => {
|
||||
console.error("Failed to load voice preview audio");
|
||||
setPlayingPreview(null);
|
||||
};
|
||||
audio.onended = () => setPlayingPreview(null);
|
||||
audio.play().catch((err) => {
|
||||
console.error("Failed to play voice preview:", err);
|
||||
setPlayingPreview(null);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (newValue === VOICE_CLONE_ID && voiceClone?.success) {
|
||||
onChange(voiceClone.custom_voice_id || VOICE_CLONE_ID);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isVoiceCloneSelected = value === VOICE_CLONE_ID ||
|
||||
(voiceClone?.success && voiceClone.custom_voice_id && value === voiceClone.custom_voice_id);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Voice</InputLabel>
|
||||
<Select
|
||||
value={isVoiceCloneSelected ? VOICE_CLONE_ID : value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
label="Voice"
|
||||
disabled={disabled}
|
||||
startAdornment={
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Mic fontSize="small" sx={{ color: isVoiceCloneSelected ? "#667eea" : "inherit" }} />
|
||||
</ListItemIcon>
|
||||
}
|
||||
>
|
||||
{voiceOptions.map((voice) => (
|
||||
<MenuItem key={voice.id} value={voice.id}>
|
||||
<ListItemText
|
||||
primary={voice.name}
|
||||
secondary={voice.isCustom ? "Custom voice clone" : voice.personality?.split(' - ')[0]}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Voice
|
||||
</Typography>
|
||||
<Tooltip title="Choose a system voice or your custom cloned voice" arrow>
|
||||
<IconButton size="small" sx={{ color: "rgba(0,0,0,0.5)" }}>
|
||||
<HelpOutline fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{showVoiceClone && loadingVoiceClone && (
|
||||
<CircularProgress size={16} sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={isVoiceCloneSelected ? VOICE_CLONE_ID : value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
renderValue={(selected) => {
|
||||
const voice = voiceOptions.find(v =>
|
||||
v.id === selected ||
|
||||
(selected === VOICE_CLONE_ID && v.isCustom)
|
||||
);
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Mic fontSize="small" sx={{ color: voice?.isCustom ? "#667eea" : "inherit" }} />
|
||||
<Typography>{voice?.name}</Typography>
|
||||
{voice?.isCustom && (
|
||||
<Chip
|
||||
label="Cloned"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha("#667eea", 0.1),
|
||||
color: "#667eea",
|
||||
height: 20,
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id && (
|
||||
<MenuItem value={VOICE_CLONE_ID} sx={{ borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<ListItemIcon>
|
||||
<AutoAwesome sx={{ color: "#667eea" }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography fontWeight={600} sx={{ color: "#667eea" }}>
|
||||
My Voice Clone
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<CheckCircle sx={{ fontSize: "14px !important" }} />}
|
||||
label="Active"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha("#10b981", 0.1),
|
||||
color: "#10b981",
|
||||
height: 20,
|
||||
fontSize: "0.65rem",
|
||||
'& .MuiChip-icon': { color: "#10b981" }
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
secondary={
|
||||
voiceClone.preview_audio_url && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={playingPreview === VOICE_CLONE_ID ? <Pause /> : <PlayArrow />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview({
|
||||
id: VOICE_CLONE_ID,
|
||||
name: voiceClone.voice_name || "My Voice Clone",
|
||||
previewUrl: voiceClone.preview_audio_url
|
||||
});
|
||||
}}
|
||||
sx={{ mt: 0.5, textTransform: 'none' }}
|
||||
>
|
||||
{playingPreview === VOICE_CLONE_ID ? "Stop" : "Preview"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem disabled sx={{ opacity: 0.6 }}>
|
||||
<Typography variant="caption">System Voices</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{voiceOptions.filter(v => !v.isCustom).map((voice) => (
|
||||
<MenuItem key={voice.id} value={voice.id}>
|
||||
<ListItemText
|
||||
primary={voice.name}
|
||||
secondary={voice.personality?.split(' - ')[0]}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedVoice?.personality && (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 0.5, display: 'block' }}>
|
||||
{selectedVoice.personality}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{showVoiceClone && !voiceClone?.success && (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: alpha("#f8fafc", 0.5), borderRadius: 2, border: '1px dashed rgba(0,0,0,0.1)' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<CloudUpload sx={{ color: "#64748b" }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Don't see your voice? Go to Onboarding → Voice Cloning to create your custom voice clone.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceSelector;
|
||||
Reference in New Issue
Block a user