Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -1,25 +1,142 @@
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
import { PodcastAnalysis } from "./types";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, TextField, IconButton, Tooltip, 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, Delete as DeleteIcon, EditNote as EditNoteIcon } 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";
interface AnalysisPanelProps {
analysis: PodcastAnalysis | null;
estimate: PodcastEstimate | null;
idea?: string;
duration?: number;
speakers?: number;
avatarUrl?: string | null;
avatarPrompt?: string | null;
onRegenerate?: () => void;
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
}
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, duration, speakers, avatarUrl, avatarPrompt, onRegenerate }) => {
const inputStyles = {
'& .MuiInputBase-input': {
color: '#111827 !important',
fontWeight: 500,
WebkitTextFillColor: '#111827 !important', // Fix for some browsers
},
'& .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',
}
},
'& .MuiSelect-select': {
color: '#111827 !important',
WebkitTextFillColor: '#111827 !important',
}
};
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
analysis,
estimate,
idea,
duration,
speakers,
avatarUrl,
avatarPrompt,
onRegenerate,
onUpdateAnalysis
}) => {
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false);
const [avatarError, setAvatarError] = useState(false);
// Edit states
const [isEditing, setIsEditing] = useState(false);
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
// Sync editedAnalysis with analysis initially
useEffect(() => {
if (analysis && !editedAnalysis) {
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
}
}, [analysis]);
const handleSave = () => {
if (editedAnalysis && onUpdateAnalysis) {
console.log('[AnalysisPanel] Saving updated analysis:', editedAnalysis);
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
}
setIsEditing(false);
};
const handleCancel = () => {
setIsEditing(false);
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
};
const updateExaConfig = (field: string, value: any) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
exaSuggestedConfig: {
...(editedAnalysis.exaSuggestedConfig || {}),
[field]: value
}
});
};
const handleAddKeyword = (keyword: string) => {
if (!editedAnalysis || !keyword.trim()) return;
if (editedAnalysis.topKeywords.includes(keyword.trim())) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: [...editedAnalysis.topKeywords, keyword.trim()]
});
};
const handleRemoveKeyword = (keyword: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: editedAnalysis.topKeywords.filter(k => k !== keyword)
});
};
const handleAddTitle = (title: string) => {
if (!editedAnalysis || !title.trim()) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: [...editedAnalysis.titleSuggestions, title.trim()]
});
};
const handleRemoveTitle = (title: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: editedAnalysis.titleSuggestions.filter(t => t !== title)
});
};
const handleUpdateOutline = (id: string | number, field: 'title' | 'segments', value: any) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
suggestedOutlines: editedAnalysis.suggestedOutlines.map(o =>
o.id === id ? { ...o, [field]: value } : o
)
});
};
// Load avatar image as blob for authenticated URLs
useEffect(() => {
@@ -93,44 +210,117 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
}, [avatarUrl]);
if (!analysis) return null;
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
console.log('[AnalysisPanel] Rendering:', { isEditing, hasEditedAnalysis: !!editedAnalysis });
return (
<GlassyCard
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28 }}
className="light-theme-container"
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
color: "#111827",
}}
aria-label="analysis-panel"
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack direction="row" alignItems="center" spacing={2} flex={1}>
<Typography
variant="h6"
sx={{
color: "#0f172a",
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1,
whiteSpace: "nowrap"
}}
>
<PsychologyIcon />
<PsychologyIcon sx={{ color: "#4f46e5" }} />
AI Analysis
</Typography>
<Typography variant="body2" color="text.secondary">
Insights derived from AI analysis of your topic and content preferences
</Typography>
</Box>
<SecondaryButton onClick={onRegenerate} startIcon={<RefreshIcon />} tooltip="Regenerate analysis with different parameters">
Regenerate
</SecondaryButton>
{estimate && (
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ ml: 2, flex: 1, overflow: 'hidden' }}>
<Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', borderColor: "rgba(0,0,0,0.1)" }} />
<Typography variant="subtitle2" fontWeight={700} sx={{ color: "#4f46e5" }}>
Est. Cost: ${estimate.total.toFixed(2)}
</Typography>
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
</Stack>
</Stack>
)}
</Stack>
<Stack direction="row" spacing={1}>
{isEditing ? (
<>
<SecondaryButton
onClick={handleSave}
startIcon={<SaveIcon />}
sx={{
color: '#059669',
borderColor: '#10b981',
bgcolor: 'white',
fontWeight: 600,
'&:hover': { bgcolor: alpha('#10b981', 0.05) }
}}
>
Save Changes
</SecondaryButton>
<SecondaryButton
onClick={handleCancel}
startIcon={<CloseIcon />}
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
>
Cancel
</SecondaryButton>
</>
) : (
<>
<SecondaryButton
onClick={() => setIsEditing(true)}
startIcon={<EditIcon />}
sx={{ color: '#4f46e5', borderColor: '#4f46e5', bgcolor: 'white', fontWeight: 600 }}
>
Edit Analysis
</SecondaryButton>
<SecondaryButton
onClick={onRegenerate}
startIcon={<RefreshIcon />}
tooltip="Regenerate analysis with different parameters"
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
>
Regenerate
</SecondaryButton>
</>
)}
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
@@ -359,31 +549,56 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
<Stack spacing={2}>
<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>
<Typography variant="body2" sx={{ color: "#0f172a" }}>
{analysis.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>
<Chip label={analysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} />
{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>
{analysis.topKeywords.map((k) => (
<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",
@@ -392,120 +607,291 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
/>
))}
</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}
</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>
</>
)}
</Paper>
))}
</Stack>
</Box>
</Stack>
<Stack spacing={2}>
{analysis.exaSuggestedConfig && (
<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>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
{analysis.exaSuggestedConfig.exa_search_type && (
<Chip
label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.exa_category && (
<Chip
label={`Category: ${analysis.exaSuggestedConfig.exa_category}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.date_range && (
<Chip
label={`Date: ${analysis.exaSuggestedConfig.date_range}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{typeof analysis.exaSuggestedConfig.include_statistics === "boolean" && (
<Chip
label={analysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.max_sources && (
<Chip
label={`Max sources: ${analysis.exaSuggestedConfig.max_sources}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
</Stack>
{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>
{(analysis.exaSuggestedConfig.exa_include_domains?.length || analysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
{analysis.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>
{analysis.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}
<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>
{analysis.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>
{analysis.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}
<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" }}>Suggested Episode Outlines</Typography>
<Stack spacing={1.5}>
{analysis.suggestedOutlines.map((o) => (
<Paper
key={o.id}
sx={{
p: 1.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.06)",
wordBreak: "break-word",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5, color: "#0f172a", wordBreak: "break-word" }}>
{o.title}
</Typography>
<Typography variant="caption" sx={{ color: "#475569", display: "block", wordBreak: "break-word" }}>
{o.segments.join(" • ")}
</Typography>
</Paper>
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.titleSuggestions.map((t) => (
<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: "pointer",
cursor: isEditing ? "default" : "pointer",
color: "#0f172a",
background: "#f8fafc",
maxWidth: "100%",
@@ -519,7 +905,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
paddingTop: 0.25,
paddingBottom: 0.25,
},
"&:hover": {
"&:hover": isEditing ? {} : {
background: alpha("#667eea", 0.15),
border: "1px solid rgba(102,126,234,0.35)",
},
@@ -527,6 +913,32 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
/>
))}
</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>
</Box>

View File

@@ -0,0 +1,306 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Stack,
TextField,
InputAdornment,
RadioGroup,
FormControlLabel,
Radio,
Typography,
CircularProgress,
Alert,
Grid,
Card,
CardMedia,
Button,
IconButton
} from '@mui/material';
import {
Search as SearchIcon,
Collections as CollectionsIcon,
CheckCircle as CheckCircleIcon,
ExpandMore as ExpandMoreIcon,
Favorite as FavoriteIcon,
FavoriteBorder as FavoriteBorderIcon
} from '@mui/icons-material';
import { useContentAssets } from '../../hooks/useContentAssets';
import { fetchMediaBlobUrl } from '../../utils/fetchMediaBlobUrl';
interface AvatarAssetBrowserProps {
onSelect: (url: string) => void;
selectedUrl: string | null;
}
export const AvatarAssetBrowser: React.FC<AvatarAssetBrowserProps> = ({ onSelect, selectedUrl }) => {
const [filter, setFilter] = useState<'all' | 'favorites'>('all');
const [search, setSearch] = useState('');
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
const [limit, setLimit] = useState(24);
const { assets, loading, error, total, toggleFavorite, refetch } = useContentAssets({
asset_type: 'image',
search: search || undefined,
favorites_only: filter === 'favorites',
limit: limit,
});
// No-op useEffect to satisfy the linter if needed, but the actual fetch is handled by useContentAssets hook's internal useEffect
// which runs when stableFilters change.
// The user reported that images don't load on initial tab mount unless toggled.
// useContentAssets's useEffect(fetchAssets, [filterKey, fetchAssets]) should handle it,
// but if it's failing initially due to auth timing, this manual refetch helps.
useEffect(() => {
// Only refetch on mount to ensure initial load
const timer = setTimeout(() => {
refetch();
}, 200); // Slightly longer delay to ensure auth is fully ready
return () => clearTimeout(timer);
}, [refetch]); // Only run on mount or if refetch function changes
// Check if a URL requires authentication (internal API endpoints)
const isAuthenticatedUrl = React.useCallback((url: string): boolean => {
if (!url) return false;
return url.includes('/api/podcast/') ||
url.includes('/api/youtube/') ||
url.includes('/api/story/') ||
(url.startsWith('/') && !url.startsWith('//'));
}, []);
// Load blob URLs for authenticated images
useEffect(() => {
if (assets.length === 0) {
setImageBlobUrls(new Map());
return;
}
const loadBlobUrls = async () => {
const newBlobUrls = new Map<number, string>();
const newLoadingImages = new Set<number>();
for (const asset of assets) {
if (!asset.file_url) continue;
if (isAuthenticatedUrl(asset.file_url)) {
newLoadingImages.add(asset.id);
try {
const blobUrl = await fetchMediaBlobUrl(asset.file_url);
if (blobUrl) {
newBlobUrls.set(asset.id, blobUrl);
}
} catch (err) {
console.error(`Failed to load image for asset ${asset.id}:`, err);
} finally {
newLoadingImages.delete(asset.id);
}
} else {
newBlobUrls.set(asset.id, asset.file_url);
}
}
setImageBlobUrls(prev => {
// Revoke old blobs that are no longer needed
prev.forEach((url, id) => {
if (url.startsWith('blob:') && !newBlobUrls.has(id)) URL.revokeObjectURL(url);
});
return newBlobUrls;
});
setLoadingImages(newLoadingImages);
};
loadBlobUrls();
// Cleanup on unmount/change is handled by the effect below or next run
}, [assets, isAuthenticatedUrl]);
// Cleanup all blobs on unmount
useEffect(() => {
return () => {
imageBlobUrls.forEach(url => {
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
});
};
}, []);
const handleLoadMore = () => {
setLimit(prev => prev + 24);
};
return (
<Box sx={{ width: '100%', height: '100%' }}>
<Stack spacing={2}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1, width: '100%' }}>
<TextField
sx={{
flexGrow: 1,
bgcolor: 'white',
'& .MuiOutlinedInput-root': {
borderRadius: 2,
'& fieldset': { borderColor: '#e2e8f0' },
'&:hover fieldset': { borderColor: '#cbd5e1' },
'&.Mui-focused fieldset': { borderColor: '#667eea' },
'& .MuiOutlinedInput-input': {
color: '#0f172a',
py: 1,
'&::placeholder': {
color: '#94a3b8',
opacity: 1,
}
}
}
}}
size="small"
placeholder="Search images..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" sx={{ color: '#64748b' }} />
</InputAdornment>
),
}}
/>
<RadioGroup
row
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'favorites')}
sx={{
flexShrink: 0,
ml: 0.5,
display: 'flex',
flexWrap: 'nowrap',
'& .MuiFormControlLabel-root': {
mr: 0.5,
ml: 0,
'& .MuiTypography-root': {
color: '#334155',
fontWeight: 600,
fontSize: '0.75rem',
whiteSpace: 'nowrap'
},
'& .MuiRadio-root': {
p: 0.5,
color: '#94a3b8',
'&.Mui-checked': {
color: '#667eea',
}
}
}
}}
>
<FormControlLabel
value="all"
control={<Radio size="small" />}
label="All"
/>
<FormControlLabel
value="favorites"
control={<Radio size="small" />}
label="Favs"
/>
</RadioGroup>
</Stack>
{loading && assets.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={24} />
</Box>
) : error ? (
<Alert severity="error">{error}</Alert>
) : assets.length === 0 ? (
<Box sx={{ textAlign: 'center', p: 4, bgcolor: '#f8fafc', borderRadius: 2 }}>
<CollectionsIcon sx={{ fontSize: 48, color: '#cbd5e1', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
{search ? 'No matches found' : 'No images in library'}
</Typography>
</Box>
) : (
<>
<Grid container spacing={1.5} sx={{ maxHeight: 300, overflowY: 'auto', pr: 0.5 }}>
{assets.map((asset) => (
<Grid item xs={6} sm={4} key={asset.id}>
<Card
sx={{
position: 'relative',
cursor: 'pointer',
border: selectedUrl === asset.file_url ? '2px solid #667eea' : '1px solid #e2e8f0',
'&:hover': { borderColor: '#667eea' }
}}
onClick={() => asset.file_url && onSelect(asset.file_url)}
>
<Box sx={{ position: 'relative', paddingTop: '100%' }}>
{isAuthenticatedUrl(asset.file_url) && !imageBlobUrls.has(asset.id) ? (
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#f8fafc' }}>
<CircularProgress size={20} />
</Box>
) : (
<CardMedia
component="img"
image={imageBlobUrls.get(asset.id) || asset.file_url || ''}
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
)}
{loadingImages.has(asset.id) && (
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(255,255,255,0.7)' }}>
<CircularProgress size={20} />
</Box>
)}
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleFavorite(asset.id);
}}
sx={{
bgcolor: 'rgba(255,255,255,0.8)',
'&:hover': { bgcolor: 'white' },
width: 24,
height: 24,
p: 0.5
}}
>
{asset.is_favorite ? <FavoriteIcon fontSize="small" color="error" /> : <FavoriteBorderIcon fontSize="small" />}
</IconButton>
{selectedUrl === asset.file_url && (
<Box sx={{ bgcolor: '#667eea', borderRadius: '50%', p: 0.5, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24 }}>
<CheckCircleIcon sx={{ color: 'white', fontSize: 16 }} />
</Box>
)}
</Box>
</Box>
</Card>
</Grid>
))}
{/* Load More Button */}
{total > limit && (
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', mt: 2, pb: 1 }}>
<Button
size="small"
variant="outlined"
onClick={handleLoadMore}
disabled={loading}
startIcon={loading ? <CircularProgress size={16} /> : <ExpandMoreIcon />}
>
{loading ? 'Loading...' : 'Load More'}
</Button>
</Grid>
)}
</Grid>
</>
)}
</Stack>
</Box>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,447 @@
import React from "react";
import { Stack, Box, Typography, Tabs, Tab, CircularProgress, Button, IconButton, Tooltip, alpha } from "@mui/material";
import {
Person as PersonIcon,
Info as InfoIcon,
CheckCircle as CheckCircleIcon,
Refresh as RefreshIcon,
Collections as CollectionsIcon,
Delete as DeleteIcon,
AutoAwesome as AutoAwesomeIcon,
CloudUpload as CloudUploadIcon,
} from "@mui/icons-material";
import { AvatarAssetBrowser } from "../AvatarAssetBrowser";
import { SecondaryButton } from "../ui";
interface AvatarSelectorProps {
avatarTab: number;
setAvatarTab: (event: React.SyntheticEvent, newValue: number) => void;
avatarFile: File | null;
avatarPreview: string | null;
avatarUrl: string | null;
loadingBrandAvatar: boolean;
handleUseBrandAvatar: () => void;
handleAvatarSelectFromLibrary: (url: string) => void;
handleAvatarChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleRemoveAvatar: () => void;
handleMakePresentable: () => void;
makingPresentable: boolean;
avatarPreviewBlobUrl: string | null;
brandAvatarFromDb?: string | null;
brandAvatarBlobUrl?: string | null;
}
export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
avatarTab,
setAvatarTab,
avatarFile,
avatarPreview,
avatarUrl,
loadingBrandAvatar,
handleUseBrandAvatar,
handleAvatarSelectFromLibrary,
handleAvatarChange,
handleRemoveAvatar,
handleMakePresentable,
makingPresentable,
avatarPreviewBlobUrl,
brandAvatarFromDb,
brandAvatarBlobUrl,
}) => {
const isAuthenticatedUrl = React.useCallback((url: string | null): boolean => {
if (!url) return false;
return url.includes('/api/podcast/') ||
url.includes('/api/youtube/') ||
url.includes('/api/story/') ||
(url.startsWith('/') && !url.startsWith('//'));
}, []);
return (
<Box
sx={{
flex: 1,
minWidth: 0,
p: 2.5,
borderRadius: 2,
background: "#ffffff",
border: "1px solid rgba(15, 23, 42, 0.08)",
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<PersonIcon fontSize="small" sx={{ color: "#667eea" }} />
</Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Podcast Presenter Avatar
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Avatar Options:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI.<br/><br/>
<strong>Brand Avatar:</strong> Use your configured brand avatar for consistency.<br/><br/>
<strong>Asset Library:</strong> Choose from your previously uploaded images.
</Typography>
</Box>
}
arrow
placement="top"
>
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help" }} />
</Tooltip>
</Stack>
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start">
{/* Left Side: Tabs & Content */}
<Box sx={{ flex: 1, width: "100%" }}>
<Tabs
value={avatarTab}
onChange={setAvatarTab}
variant="scrollable"
scrollButtons="auto"
sx={{
mb: 3,
minHeight: 48,
"& .MuiTabs-indicator": {
display: "none",
},
"& .MuiTabs-flexContainer": {
gap: 1.5,
},
"& .MuiTab-root": {
textTransform: "none",
minHeight: 44,
fontWeight: 600,
fontSize: "0.875rem",
borderRadius: "12px",
px: 2.5,
color: "#64748b",
border: "1.5px solid #e2e8f0",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
backgroundColor: "#ffffff",
"&:hover": {
borderColor: "#cbd5e1",
backgroundColor: "#f8fafc",
transform: "translateY(-1px)",
},
"&.Mui-selected": {
color: "#ffffff",
borderColor: "transparent",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
},
},
}}
>
<Tab label="Use Brand Avatar" />
<Tab label="Asset Library" />
<Tab label="Upload Your Photo" />
</Tabs>
{avatarTab === 0 && (
<Stack spacing={2}>
<Box sx={{ minHeight: 200, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", bgcolor: "#f8fafc", borderRadius: 2, p: 2, position: "relative" }}>
{loadingBrandAvatar ? (
<CircularProgress size={32} />
) : avatarPreview && avatarPreview === brandAvatarFromDb ? (
<Stack spacing={2} alignItems="center">
<Box sx={{ position: "relative" }}>
<Box
component="img"
src={avatarPreviewBlobUrl || ""}
alt="Selected Brand Avatar"
sx={{
width: 160,
height: 160,
objectFit: "cover",
borderRadius: 2.5,
border: "2px solid #667eea",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
}}
/>
<IconButton
size="small"
onClick={handleRemoveAvatar}
sx={{
position: "absolute",
top: -8,
right: -8,
bgcolor: "white",
border: "1.5px solid #e2e8f0",
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
"&:hover": {
bgcolor: "#fef2f2",
borderColor: "#ef4444",
color: "#ef4444",
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
<Stack direction="row" alignItems="center" spacing={1}>
<CheckCircleIcon color="primary" fontSize="small" />
<Typography variant="body2" sx={{ color: "#64748b", fontStyle: "italic" }}>
Active Presenter
</Typography>
</Stack>
</Stack>
) : brandAvatarFromDb ? (
<Stack spacing={2} alignItems="center">
<Box
component="img"
src={brandAvatarBlobUrl || ""}
alt="Available Brand Avatar"
sx={{
width: 160,
height: 160,
objectFit: "cover",
borderRadius: 2.5,
border: "1.5px solid #e2e8f0",
opacity: 0.8,
filter: "grayscale(0.3)",
}}
/>
<Button
variant="contained"
size="small"
onClick={handleUseBrandAvatar}
startIcon={<CheckCircleIcon />}
sx={{
borderRadius: "8px",
textTransform: "none",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
}}
>
Use this Avatar
</Button>
</Stack>
) : (
<Stack spacing={2} alignItems="center">
<PersonIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
<Typography variant="body2" color="text.secondary">
No brand avatar found.
</Typography>
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void handleUseBrandAvatar()}>
Retry
</Button>
</Stack>
)}
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f0f4ff", 0.6),
border: "1px solid rgba(99, 102, 241, 0.2)",
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea" }} />
Brand Avatar
</Typography>
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
Select your pre-configured brand avatar to maintain consistency. If not selected, a new AI presenter will be generated.
</Typography>
</Box>
</Stack>
)}
{avatarTab === 1 && (
<Stack spacing={2}>
<Box sx={{ minHeight: 300, position: "relative" }}>
{avatarPreview && !avatarFile && (
<IconButton
size="small"
onClick={handleRemoveAvatar}
sx={{
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
bgcolor: "white",
border: "1.5px solid #e2e8f0",
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
"&:hover": {
bgcolor: "#fef2f2",
borderColor: "#ef4444",
color: "#ef4444",
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<AvatarAssetBrowser
selectedUrl={avatarUrl}
onSelect={(url) => handleAvatarSelectFromLibrary(url)}
/>
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f8fafc", 0.8),
border: "1px solid rgba(15, 23, 42, 0.1)",
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
<CollectionsIcon fontSize="small" sx={{ color: "#64748b" }} />
Asset Library
</Typography>
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
Select from your previously uploaded images. Filter by favorites or search to find the perfect presenter.
</Typography>
</Box>
</Stack>
)}
{avatarTab === 2 && (
<Stack spacing={2}>
<Box>
{avatarFile && avatarPreview ? (
<Stack spacing={2} alignItems="center" sx={{ bgcolor: "#f8fafc", borderRadius: 2, p: 2 }}>
<Box sx={{ position: "relative", display: "inline-block" }}>
<Box
component="img"
src={avatarPreviewBlobUrl || (avatarPreview.startsWith("data:") ? avatarPreview : "")}
alt="Avatar preview"
sx={{
width: 160,
height: 160,
objectFit: "cover",
borderRadius: 2.5,
border: "2px solid #e2e8f0",
boxShadow: "0 2px 8px rgba(15, 23, 42, 0.08)",
}}
/>
<IconButton
size="small"
onClick={handleRemoveAvatar}
sx={{
position: "absolute",
top: -8,
right: -8,
bgcolor: "white",
border: "1.5px solid #e2e8f0",
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
"&:hover": {
bgcolor: "#f8fafc",
borderColor: "#dc2626",
color: "#dc2626",
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{avatarUrl && (
<Tooltip
title="Transform your uploaded photo into a professional podcast presenter."
arrow
placement="top"
>
<Box>
<SecondaryButton
onClick={handleMakePresentable}
disabled={makingPresentable}
loading={makingPresentable}
startIcon={!makingPresentable ? <AutoAwesomeIcon fontSize="small" /> : undefined}
sx={{ width: "100%" }}
>
{makingPresentable ? "Transforming..." : "Make Presentable"}
</SecondaryButton>
</Box>
</Tooltip>
)}
</Stack>
) : (
<Box
component="label"
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
minHeight: 200,
border: "2px dashed #cbd5e1",
borderRadius: 2.5,
bgcolor: "#f8fafc",
cursor: "pointer",
transition: "all 0.2s",
"&:hover": {
borderColor: "#667eea",
bgcolor: "#f1f5f9",
borderWidth: "2.5px",
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
},
}}
>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
style={{ display: "none" }}
/>
<CloudUploadIcon sx={{ color: "#94a3b8", fontSize: 36, mb: 1.5 }} />
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600, mb: 0.5 }}>
Upload Your Photo
</Typography>
<Typography variant="caption" sx={{ color: "#94a3b8", textAlign: "center", px: 2, lineHeight: 1.5 }}>
JPG, PNG, WebP (max 5MB)
</Typography>
</Box>
)}
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f8fafc", 0.8),
border: "1px solid rgba(15, 23, 42, 0.1)",
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
<CloudUploadIcon fontSize="small" sx={{ color: "#64748b" }} />
Upload Your Photo
</Typography>
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
Upload a new photo and use <strong>"Make Presentable"</strong> to enhance it into a professional presenter using AI.
</Typography>
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f0f4ff", 0.5),
border: "1px solid rgba(99, 102, 241, 0.15)",
}}
>
<Typography variant="caption" sx={{ color: "#6366f1", fontSize: "0.8125rem", fontWeight: 500, display: "flex", alignItems: "center", gap: 0.5 }}>
<InfoIcon fontSize="inherit" />
Supported formats: JPG, PNG, WebP (max 5MB)
</Typography>
</Box>
</Stack>
)}
</Box>
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,60 @@
import React from "react";
import { Stack, Alert, Typography, alpha } from "@mui/material";
import {
Info as InfoIcon,
Refresh as RefreshIcon,
AutoAwesome as AutoAwesomeIcon,
} from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
interface CreateActionsProps {
reset: () => void;
submit: () => void;
canSubmit: boolean;
isSubmitting: boolean;
}
export const CreateActions: React.FC<CreateActionsProps> = ({
reset,
submit,
canSubmit,
isSubmitting,
}) => {
return (
<Stack spacing={3.5}>
{/* Info Banner */}
<Alert
severity="info"
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
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 existing images from asset library Or Upload your Picture. If not, AI Avatar will be generated automatically.
</Typography>
</Alert>
<Stack direction="row" justifyContent="flex-end" spacing={1}>
<SecondaryButton onClick={reset} startIcon={<RefreshIcon />}>
Reset
</SecondaryButton>
<PrimaryButton
onClick={submit}
disabled={!canSubmit || isSubmitting}
loading={isSubmitting}
startIcon={<AutoAwesomeIcon />}
tooltip={!canSubmit ? "Enter an idea or URL to continue" : "Well start AI analysis after this click"}
>
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
</PrimaryButton>
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,227 @@
import React from 'react';
import { Stack, Box, Typography, Tooltip, IconButton, Chip, alpha } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
HelpOutline as HelpOutlineIcon,
AttachMoney as AttachMoneyIcon,
} from '@mui/icons-material';
import { Knobs } from '../types';
interface CreateHeaderProps {
subscription: any;
duration: number;
speakers: number;
knobs: Knobs;
estimatedCost: {
ttsCost: number;
avatarCost: number;
videoCost: number;
researchCost: number;
total: number;
};
}
export const CreateHeader: React.FC<CreateHeaderProps> = ({
subscription,
duration,
speakers,
knobs,
estimatedCost,
}) => {
return (
<Stack direction="row" spacing={2} alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={2}>
<Stack direction="row" spacing={2} alignItems="flex-start" sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1.75rem" }} />
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography
variant="h5"
sx={{
color: "#0f172a",
fontWeight: 700,
fontSize: { xs: "1.5rem", md: "1.75rem" },
letterSpacing: "-0.02em",
lineHeight: 1.2,
}}
>
Create New Podcast Episode
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Tips for best results:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem" }}>
Provide one clear topic OR a single blog URL (we won't auto-run anything).<br />
Keep it conciseone sentence topic works best.<br />
We start analysis only after you confirm, so you stay in control.
</Typography>
</Box>
}
arrow
placement="top"
componentsProps={{
tooltip: {
sx: {
bgcolor: "#0f172a",
color: "#ffffff",
maxWidth: 300,
fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
arrow: {
sx: {
color: "#0f172a",
},
},
}}
>
<IconButton
size="small"
sx={{
color: "#64748b",
"&:hover": {
color: "#667eea",
backgroundColor: alpha("#667eea", 0.08),
}
}}
>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ alignItems: "center" }}>
<Tooltip
title={`Your current subscription plan: ${subscription?.tier || "free"}. Upgrade for more features.`}
arrow
placement="top"
>
<Chip
label={`Plan: ${subscription?.tier || "free"}`}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
color: "#667eea",
fontWeight: 600,
border: "1px solid rgba(102, 126, 234, 0.2)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={`Podcast duration: ${duration} minutes. Maximum duration is 10 minutes. Recommended: 5-10 minutes for best results.`}
arrow
placement="top"
>
<Chip
label={`Duration: ${duration} min`}
size="small"
sx={{
background: alpha("#0f172a", 0.06),
color: "#0f172a",
fontWeight: 600,
border: "1px solid rgba(15, 23, 42, 0.12)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={`Number of speakers: ${speakers}. Supports 1-2 speakers. Each additional speaker adds avatar generation cost.`}
arrow
placement="top"
>
<Chip
label={`${speakers} speaker${speakers > 1 ? "s" : ""}`}
size="small"
sx={{
background: alpha("#0f172a", 0.06),
color: "#0f172a",
fontWeight: 600,
border: "1px solid rgba(15, 23, 42, 0.12)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Estimated Cost Breakdown:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
Audio Generation: ${estimatedCost.ttsCost}<br />
Avatar Creation: ${estimatedCost.avatarCost}<br />
Video Rendering: ${estimatedCost.videoCost}<br />
Research: ${estimatedCost.researchCost}<br />
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.5, pt: 0.5, borderTop: "1px solid rgba(255,255,255,0.2)" }}>
Total: ${estimatedCost.total}
</Typography>
<Typography variant="caption" sx={{ fontSize: "0.75rem", opacity: 0.9, mt: 0.5, display: "block" }}>
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {knobs.bitrate === "hd" ? "HD" : "standard"} quality
</Typography>
</Typography>
</Box>
}
arrow
placement="top"
componentsProps={{
tooltip: {
sx: {
bgcolor: "#0f172a",
color: "#ffffff",
maxWidth: 280,
fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
arrow: {
sx: {
color: "#0f172a",
},
},
}}
>
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`Est. $${estimatedCost.total}`}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,152 @@
import React from "react";
import { Stack, Box, Typography, TextField, ToggleButton, ToggleButtonGroup, alpha } from "@mui/material";
import { Person as PersonIcon, Group as GroupIcon } from "@mui/icons-material";
interface PodcastConfigurationProps {
duration: number;
setDuration: (value: number) => void;
speakers: number;
setSpeakers: (value: number) => void;
}
export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
duration,
setDuration,
speakers,
setSpeakers,
}) => {
const handleDurationChange = (value: number) => {
const clamped = Math.min(10, Math.max(1, value));
setDuration(clamped);
};
const handleSpeakersChange = (
event: React.MouseEvent<HTMLElement>,
newValue: number | null
) => {
if (newValue !== null) {
setSpeakers(newValue);
}
};
return (
<Box
sx={{
flex: { xs: "1 1 auto", lg: "0 0 320px" },
width: { xs: "100%", lg: "320px" },
p: 3,
borderRadius: 2,
background: alpha("#f8fafc", 0.5),
border: "1px solid rgba(15, 23, 42, 0.06)",
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<Typography variant="subtitle2" sx={{ mb: 2.5, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Basic Configuration
</Typography>
<Stack spacing={3}>
{/* Duration Input */}
<Box>
<Typography variant="caption" sx={{ display: "block", mb: 1, color: "#64748b", fontWeight: 500 }}>
Duration (minutes)
</Typography>
<TextField
type="number"
value={duration}
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
InputProps={{ inputProps: { min: 1, max: 10 } }}
size="small"
helperText={duration > 10 ? "Maximum duration is 10 minutes" : "Recommended: 1-3 mins"}
error={duration > 10}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#ffffff",
border: "1px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
transition: "all 0.2s",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.6)",
},
"&.Mui-focused": {
borderColor: "#667eea",
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.1)",
},
},
"& .MuiOutlinedInput-input": {
color: "#0f172a",
fontWeight: 600,
fontSize: "0.9375rem",
},
"& .MuiFormHelperText-root": {
color: duration > 10 ? "#dc2626" : "#64748b",
fontSize: "0.75rem",
mt: 0.75,
},
}}
/>
</Box>
{/* Speakers Toggle */}
<Box>
<Typography variant="caption" sx={{ display: "block", mb: 1, color: "#64748b", fontWeight: 500 }}>
Number of Speakers
</Typography>
<ToggleButtonGroup
value={speakers}
exclusive
onChange={handleSpeakersChange}
fullWidth
size="small"
sx={{
backgroundColor: "#ffffff",
border: "1px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
p: 0.5,
"& .MuiToggleButton-root": {
border: "none",
borderRadius: 1.5,
color: "#64748b",
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
py: 1,
transition: "all 0.2s ease",
"&:hover": {
backgroundColor: alpha("#64748b", 0.05),
},
"&.Mui-selected": {
backgroundColor: alpha("#667eea", 0.1),
color: "#667eea",
fontWeight: 600,
"&:hover": {
backgroundColor: alpha("#667eea", 0.15),
},
},
},
}}
>
<ToggleButton value={1} aria-label="1 speaker">
<Stack direction="row" spacing={1} alignItems="center">
<PersonIcon fontSize="small" />
<Typography variant="body2">1 Speaker</Typography>
</Stack>
</ToggleButton>
<ToggleButton value={2} aria-label="2 speakers">
<Stack direction="row" spacing={1} alignItems="center">
<GroupIcon fontSize="small" />
<Typography variant="body2">2 Speakers</Typography>
</Stack>
</ToggleButton>
</ToggleButtonGroup>
<Typography variant="caption" sx={{ display: "block", mt: 0.75, color: "#64748b", fontSize: "0.75rem" }}>
{speakers === 1 ? "Single host format" : "Host and guest conversation"}
</Typography>
</Box>
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,143 @@
import React from "react";
import { Box, Typography, TextField, Tooltip, Button, alpha } from "@mui/material";
import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
export const TOPIC_PLACEHOLDERS = [
"Industry insights: Latest trends in AI for Content Marketing",
"Product deep-dive: How our new feature solves common pain points",
"Educational: 5 ways to improve your workflow with automation",
"Thought leadership: The future of decentralized finance (DeFi)",
"Interview prep: Key questions for your next tech hiring round",
"Podcast prep: Analyzing the impact of remote work on mental health",
];
interface TopicUrlInputProps {
value: string;
onChange: (value: string) => void;
isUrl: boolean;
showAIDetailsButton: boolean;
onAIDetailsClick?: () => void;
placeholderIndex: number;
loading?: boolean;
}
export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
value,
onChange,
isUrl,
showAIDetailsButton,
onAIDetailsClick,
placeholderIndex,
loading = false,
}) => {
return (
<Box
sx={{
p: 3,
borderRadius: 2,
background: alpha("#f8fafc", 0.5),
border: "1px solid rgba(15, 23, 42, 0.06)",
height: "100%", // Fill height of parent
display: "flex",
flexDirection: "column",
}}
>
<Box flex={1} display="flex" flexDirection="column">
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Topic Idea or Blog URL
</Typography>
<Tooltip
title={
isUrl
? "We detected a URL. We'll fetch insights from this page."
: "Enter a concise idea or paste a blog URL."
}
arrow
placement="top"
>
<TextField
fullWidth
multiline
rows={5}
placeholder={!value ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}" or paste a URL` : ""}
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
color: "#0f172a",
},
}}
value={value}
onChange={(e) => onChange(e.target.value)}
size="small"
helperText={
isUrl
? "URL detected. We'll analyze this page content."
: "Enter a clear, concise topic. We'll expand it into a full script after you click Analyze."
}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": {
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: isUrl ? "#10b981" : "#667eea", // Green for URL, Blue for Topic
borderWidth: 2,
},
},
"& .MuiOutlinedInput-input": {
fontSize: "0.9375rem",
lineHeight: 1.6,
color: "#0f172a",
fontWeight: 400,
},
"& .MuiInputBase-input::placeholder": {
color: "#94a3b8",
opacity: 1,
fontWeight: 400,
},
"& .MuiFormHelperText-root": {
color: isUrl ? "#059669" : "#64748b",
fontSize: "0.8125rem",
fontWeight: 400,
mt: 0.75,
},
}}
/>
</Tooltip>
{/* Add details with AI button - appears when user types (and not a URL) */}
{showAIDetailsButton && !isUrl && (
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<AutoAwesomeIcon />}
onClick={onAIDetailsClick}
disabled={loading}
sx={{
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 600,
borderColor: "#667eea",
borderWidth: 1.5,
color: "#667eea",
borderRadius: 2,
"&:hover": {
borderColor: "#5568d3",
backgroundColor: alpha("#667eea", 0.08),
},
}}
>
{loading ? "Enhancing..." : "Add details with AI"}
</Button>
</Box>
)}
</Box>
</Box>
);
};

View File

@@ -48,6 +48,23 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
}}
>
<Stack spacing={1} sx={{ flex: 1, minHeight: 0 }}>
{/* Source Image */}
{fact.image && (
<Box
component="img"
src={fact.image}
alt={fact.url}
sx={{
width: "100%",
height: 120,
objectFit: "cover",
borderRadius: 1,
mb: 1,
border: "1px solid rgba(0,0,0,0.04)"
}}
/>
)}
{/* Quote Text - Truncated with expand option */}
<Box sx={{ flex: 1, minHeight: 0 }}>
<Typography
@@ -66,6 +83,21 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
>
{expanded ? fullText : previewText}
</Typography>
{/* Highlights */}
{fact.highlights && fact.highlights.length > 0 && expanded && (
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px dashed rgba(0,0,0,0.06)" }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: "#64748b", mb: 0.5, display: "block" }}>
Highlights:
</Typography>
{fact.highlights.slice(0, 2).map((highlight, idx) => (
<Typography key={idx} variant="caption" sx={{ display: "block", color: "#475569", mb: 0.5, fontStyle: "italic" }}>
"{highlight}"
</Typography>
))}
</Box>
)}
{shouldTruncate && (
<IconButton
size="small"

View File

@@ -162,7 +162,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
setCurrentTime(newTime);
};
const effectiveAudioUrl = blobUrl || audioUrl;
const isPodcastAudio = audioUrl.includes('/api/podcast/audio/') || audioUrl.includes('/api/story/audio/');
const effectiveAudioUrl = blobUrl || (!isPodcastAudio ? audioUrl : null);
return (
<Paper

View File

@@ -0,0 +1,186 @@
import React from 'react';
import {
Box,
Typography,
Stack,
TextField,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
IconButton,
Tooltip
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
AutoFixHigh as AutoFixHighIcon,
Psychology as PsychologyIcon,
Groups as GroupsIcon,
BrandingWatermark as BrandIcon,
Info as InfoIcon
} from '@mui/icons-material';
interface PodcastBiblePanelProps {
bible: any;
onUpdate: (updatedBible: any) => void;
}
export const PodcastBiblePanel: React.FC<PodcastBiblePanelProps> = ({ bible, onUpdate }) => {
if (!bible) return null;
const handleUpdateHost = (field: string, value: any) => {
onUpdate({
...bible,
host: { ...bible.host, [field]: value }
});
};
const handleUpdateAudience = (field: string, value: any) => {
onUpdate({
...bible,
audience: { ...bible.audience, [field]: value }
});
};
const handleUpdateBrand = (field: string, value: any) => {
onUpdate({
...bible,
brand: { ...bible.brand, [field]: value }
});
};
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>
<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>
{/* 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>
</Box>
);
};

View File

@@ -1,7 +1,6 @@
import React, { useState, useCallback } from "react";
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
import { Script } from "./types";
import { CreateModal } from "./CreateModal";
import { AnalysisPanel } from "./AnalysisPanel";
import { ScriptEditor } from "./ScriptEditor";
@@ -9,12 +8,14 @@ import { RenderQueue } from "./RenderQueue";
import { RecentEpisodesPreview } from "./RecentEpisodesPreview";
import { ProjectList } from "./ProjectList";
import { PreflightBlockDialog } from "./PreflightBlockDialog";
import { PodcastBiblePanel } from "./PodcastBiblePanel";
import {
Header,
ProgressStepper,
EstimateCard,
QuerySelection,
ResearchSummary,
RegenerationFeedbackModal,
usePodcastWorkflow,
DEFAULT_KNOBS,
getStepLabel,
@@ -38,7 +39,9 @@ const PodcastDashboard: React.FC = () => {
showScriptEditor,
showRenderQueue,
currentStep,
bible,
setScriptData,
setBible,
setShowScriptEditor,
setShowRenderQueue,
setResearchProvider,
@@ -56,6 +59,8 @@ const PodcastDashboard: React.FC = () => {
},
});
const [showRegenModal, setShowRegenModal] = useState(false);
const handleSelectProject = useCallback(async (projectId: string) => {
try {
await loadProjectFromDb(projectId);
@@ -65,7 +70,7 @@ const PodcastDashboard: React.FC = () => {
// Use workflow's setAnnouncement - workflow is stable from hook
workflow.setAnnouncement(errorMsg);
}
}, [loadProjectFromDb, workflow.setAnnouncement]);
}, [loadProjectFromDb, workflow]);
const handleNewEpisode = useCallback(() => {
resetState();
@@ -184,6 +189,14 @@ const PodcastDashboard: React.FC = () => {
</Alert>
)}
{/* Podcast Bible */}
{project && bible && (currentStep === 'analysis' || (currentStep === 'research' && !research)) && !showScriptEditor && !showRenderQueue && (
<PodcastBiblePanel
bible={bible}
onUpdate={(updated) => setBible(updated)}
/>
)}
{(workflow.isAnalyzing || workflow.isResearching) && (
<Alert
severity="warning"
@@ -214,23 +227,22 @@ const PodcastDashboard: React.FC = () => {
{/* Main Content */}
<Stack spacing={3}>
{analysis && !showScriptEditor && !showRenderQueue && (
{analysis && (currentStep === 'analysis' || (currentStep === 'research' && !research)) && !showScriptEditor && !showRenderQueue && (
<AnalysisPanel
analysis={analysis}
estimate={estimate}
idea={project?.idea}
duration={project?.duration}
speakers={project?.speakers}
avatarUrl={project?.avatarUrl}
avatarPrompt={project?.avatarPrompt}
onRegenerate={() => {}}
onRegenerate={() => setShowRegenModal(true)}
onUpdateAnalysis={(updated) => projectState.setAnalysis(updated)}
/>
)}
{estimate && !showScriptEditor && !showRenderQueue && (
<EstimateCard estimate={estimate} />
)}
{queries.length > 0 && !showScriptEditor && !showRenderQueue && (
{/* Main content area */}
{queries.length > 0 && currentStep === 'research' && !research && !showScriptEditor && !showRenderQueue && (
<QuerySelection
queries={queries}
selectedQueries={selectedQueries}
@@ -242,7 +254,7 @@ const PodcastDashboard: React.FC = () => {
/>
)}
{research && !showScriptEditor && !showRenderQueue && (
{research && (currentStep === 'research' || currentStep === 'script') && !showScriptEditor && !showRenderQueue && (
<ResearchSummary
research={research}
canGenerateScript={workflow.canGenerateScript}
@@ -260,6 +272,8 @@ const PodcastDashboard: React.FC = () => {
speakers={project.speakers}
durationMinutes={project.duration}
script={scriptData}
analysis={analysis}
outline={analysis?.suggestedOutlines?.[0]}
onScriptChange={(s) => setScriptData(s)}
onBackToResearch={() => setShowScriptEditor(false)}
onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
@@ -280,8 +294,10 @@ const PodcastDashboard: React.FC = () => {
script={scriptData}
knobs={knobsState}
jobs={renderJobs}
bible={bible}
budgetCap={projectState.budgetCap}
avatarImageUrl={null}
analysis={analysis} // Pass analysis context
onUpdateJob={updateRenderJob}
onUpdateScript={(updatedScript) => setScriptData(updatedScript)}
onBack={() => {
@@ -305,6 +321,17 @@ const PodcastDashboard: React.FC = () => {
response={workflow.preflightResponse}
operationName={workflow.preflightOperationName}
/>
{/* Regeneration Feedback Modal */}
<RegenerationFeedbackModal
open={showRegenModal}
onClose={() => setShowRegenModal(false)}
onConfirm={async (feedback) => {
setShowRegenModal(false);
await workflow.handleRegenerate(feedback);
}}
isSubmitting={workflow.isAnalyzing}
/>
</Box>
);
};

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Box, Stack, Typography } from "@mui/material";
import { Stack, Typography } from "@mui/material";
import {
Mic as MicIcon,
Info as InfoIcon,
@@ -19,21 +19,19 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
const navigate = useNavigate();
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
flexWrap="wrap"
gap={2}
sx={{ width: "100%", minWidth: 0 }} // Ensure full width and allow wrapping
>
<Box sx={{ minWidth: 0, flex: { xs: "1 1 100%", md: "0 1 auto" } }}>
<Stack sx={{ width: "100%", minWidth: 0 }} spacing={1.5}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
flexWrap="wrap"
gap={2}
>
<Typography
variant="h3"
sx={{
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
@@ -43,89 +41,86 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: "none", sm: "block" } }}>
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack
direction="row"
spacing={1}
alignItems="center"
flexWrap="wrap"
useFlexGap
sx={{
justifyContent: { xs: "flex-start", md: "flex-end" },
gap: { xs: 0.5, md: 1 },
minWidth: 0,
width: { xs: "100%", md: "auto" }, // Full width on mobile to allow wrapping
flex: { xs: "1 1 100%", md: "0 1 auto" }, // Take full width on mobile
}}
>
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton
onClick={() => window.open("/docs", "_blank")}
startIcon={<InfoIcon />}
sx={{
display: { xs: "none", lg: "flex" },
// Override for light theme
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
<Stack
direction="row"
spacing={1}
alignItems="center"
flexWrap="wrap"
useFlexGap
sx={{
justifyContent: { xs: "flex-start", md: "flex-end" },
gap: { xs: 0.5, md: 1 },
minWidth: 0,
}}
>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
sx={{
display: { xs: "none", xl: "flex" },
// Override for light theme
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={onShowProjects}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
sx={{
flexShrink: 0,
display: "flex !important", // Always show "My Projects" - force display
order: { xs: 1, md: 0 }, // Show first on mobile
// Override button colors for light theme
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
My Projects
</SecondaryButton>
<PrimaryButton
onClick={onNewEpisode}
startIcon={<AutoAwesomeIcon />}
sx={{
flexShrink: 0,
display: "flex", // Always show "New Episode"
order: { xs: 0, md: 1 }, // Show first on mobile
}}
>
New Episode
</PrimaryButton>
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton
onClick={() => window.open("/docs", "_blank")}
startIcon={<InfoIcon />}
sx={{
display: { xs: "none", lg: "flex" },
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
sx={{
display: { xs: "none", xl: "flex" },
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={onShowProjects}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
sx={{
flexShrink: 0,
display: "flex !important",
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
My Projects
</SecondaryButton>
<PrimaryButton
onClick={onNewEpisode}
startIcon={<AutoAwesomeIcon />}
sx={{
flexShrink: 0,
display: "flex",
}}
>
New Episode
</PrimaryButton>
</Stack>
</Stack>
<Typography
variant="body2"
color="text.secondary"
sx={{ display: { xs: "none", sm: "block" } }}
>
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Stack>
);
};

View File

@@ -0,0 +1,168 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
TextField,
Box,
Stack,
Chip,
alpha,
IconButton
} from '@mui/material';
import {
Psychology as PsychologyIcon,
Close as CloseIcon,
AutoAwesome as AutoAwesomeIcon,
RecordVoiceOver as VoiceIcon,
Groups as AudienceIcon,
FormatListBulleted as OutlineIcon
} from '@mui/icons-material';
interface RegenerationFeedbackModalProps {
open: boolean;
onClose: () => void;
onConfirm: (feedback: string) => void;
isSubmitting?: boolean;
}
const feedbackOptions = [
{ label: 'Audience is wrong', icon: <AudienceIcon fontSize="small" />, text: 'The target audience is not quite right. It should be more focused on...' },
{ label: 'Too generic', icon: <AutoAwesomeIcon fontSize="small" />, text: 'The analysis feels a bit generic. Can we make it more specific to...' },
{ label: 'Outline needs work', icon: <OutlineIcon fontSize="small" />, text: 'The suggested episode outlines don\'t capture the depth I want. Let\'s try...' },
{ label: 'Wrong tone', icon: <VoiceIcon fontSize="small" />, text: 'The content type and tone don\'t match my brand. I want it to be more...' },
];
export const RegenerationFeedbackModal: React.FC<RegenerationFeedbackModalProps> = ({
open,
onClose,
onConfirm,
isSubmitting = false
}) => {
const [feedback, setFeedback] = useState('');
const handleOptionClick = (text: string) => {
setFeedback(prev => prev ? `${prev}\n${text}` : text);
};
const handleSubmit = () => {
onConfirm(feedback.trim());
setFeedback('');
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
bgcolor: '#ffffff',
backgroundImage: 'none'
}
}}
>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" alignItems="center" spacing={1}>
<PsychologyIcon sx={{ color: '#4f46e5' }} />
<Typography variant="h6" fontWeight={800} sx={{ color: '#1e293b' }}>
Improve AI Analysis
</Typography>
</Stack>
<IconButton onClick={onClose} size="small" sx={{ color: '#64748b' }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 3, pt: 1 }}>
<Typography variant="body2" sx={{ color: '#475569', mb: 3 }}>
Tell us what you'd like to change or improve about the previous analysis. Your feedback will help the AI generate a more accurate plan for your podcast.
</Typography>
<Stack spacing={3}>
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, display: 'block', mb: 1.5 }}>
QUICK SUGGESTIONS
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{feedbackOptions.map((opt) => (
<Chip
key={opt.label}
label={opt.label}
icon={opt.icon}
onClick={() => handleOptionClick(opt.text)}
sx={{
bgcolor: alpha('#4f46e5', 0.05),
color: '#4f46e5',
border: '1px solid',
borderColor: alpha('#4f46e5', 0.2),
fontWeight: 600,
'&:hover': {
bgcolor: alpha('#4f46e5', 0.1),
borderColor: '#4f46e5',
}
}}
/>
))}
</Stack>
</Box>
<TextField
fullWidth
multiline
rows={4}
placeholder="e.g. Focus more on technical details for developers, or make the tone more humorous and conversational..."
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
variant="outlined"
autoFocus
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: '#f8fafc',
borderRadius: 2,
'& fieldset': { borderColor: '#e2e8f0' },
'&:hover fieldset': { borderColor: '#cbd5e1' },
'&.Mui-focused fieldset': { borderColor: '#4f46e5' },
},
'& .MuiInputBase-input': {
color: '#1e293b',
fontSize: '0.95rem'
}
}}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button
onClick={onClose}
sx={{ color: '#64748b', textTransform: 'none', fontWeight: 600 }}
>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={!feedback.trim() || isSubmitting}
sx={{
bgcolor: '#4f46e5',
color: 'white',
px: 4,
borderRadius: 2,
textTransform: 'none',
fontWeight: 700,
'&:hover': { bgcolor: '#4338ca' },
'&.Mui-disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' }
}}
>
{isSubmitting ? 'Regenerating...' : 'Regenerate Analysis'}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React, { useMemo, useCallback } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper } from "@mui/material";
import {
Insights as InsightsIcon,
@@ -6,8 +6,9 @@ import {
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon,
Article as ArticleIcon,
AutoAwesome as AutoAwesomeIcon,
} from "@mui/icons-material";
import { Research } from "../types";
import { Research, ResearchInsight } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard";
@@ -22,75 +23,46 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
canGenerateScript,
onGenerateScript,
}) => {
// Extract key insights from summary if it's long
const summaryParts = useMemo(() => {
const fullSummary = research.summary || "";
if (fullSummary.length > 500) {
// Try to split into paragraphs or sentences
const sentences = fullSummary.split(/[.!?]\s+/).filter(s => s.trim().length > 20);
const keyPoints = sentences.slice(0, 3);
const remainingText = sentences.slice(3).join(". ") + (sentences.length > 3 ? "." : "");
return { keyPoints, remainingText };
}
return { keyPoints: [], remainingText: fullSummary };
}, [research.summary]);
// Simple markdown-to-HTML converter
const renderMarkdown = useCallback((text: string) => {
if (!text) return null;
return text
.split('\n')
.filter(line => line.trim() !== '') // Remove empty lines
.map((line, i) => {
// Handle bold
let processedLine = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Handle lists
if (processedLine.trim().startsWith('- ') || processedLine.trim().startsWith('* ')) {
return <li key={i} dangerouslySetInnerHTML={{ __html: processedLine.trim().substring(2) }} style={{ marginBottom: '4px', fontSize: '0.9rem' }} />;
}
// Handle headers - make them smaller
if (processedLine.startsWith('### ')) {
return <Typography key={i} variant="subtitle2" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#1e293b' }}>{processedLine.substring(4)}</Typography>;
}
if (processedLine.startsWith('## ')) {
return <Typography key={i} variant="subtitle1" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#0f172a' }}>{processedLine.substring(3)}</Typography>;
}
// Paragraphs - compact spacing
return processedLine.trim() ? <p key={i} dangerouslySetInnerHTML={{ __html: processedLine }} style={{ margin: '4px 0', fontSize: '0.9rem' }} /> : null;
});
}, []);
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1.5 }}>
<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 }}>
<InsightsIcon />
Research Summary
</Typography>
{/* Key Insights */}
{summaryParts.keyPoints.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600, display: "flex", alignItems: "center", gap: 0.5 }}>
<ArticleIcon fontSize="small" />
Key Insights
</Typography>
<Stack spacing={1}>
{summaryParts.keyPoints.map((point, idx) => (
<Paper
key={idx}
sx={{
p: 1.25,
background: alpha("#667eea", 0.05),
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 1.5,
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.6, fontSize: "0.875rem" }}>
{point}
</Typography>
</Paper>
))}
</Stack>
</Box>
)}
{/* Full Summary Text */}
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 2,
lineHeight: 1.7,
fontSize: "0.875rem",
color: "#475569",
}}
>
{summaryParts.remainingText || research.summary}
</Typography>
{/* Research Metadata */}
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
{/* Research Metadata - Moved alongside title */}
<Stack direction="row" spacing={1.5} flexWrap="wrap">
{research.searchQueries && research.searchQueries.length > 0 && (
<Chip
icon={<SearchIcon />}
icon={<SearchIcon sx={{ fontSize: "1rem !important" }} />}
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
size="small"
sx={{
@@ -139,32 +111,8 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
/>
)}
</Stack>
</Stack>
{/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600 }}>
Search Queries Used
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{research.searchQueries.map((query, idx) => (
<Chip
key={idx}
label={query}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(102, 126, 234, 0.3)",
color: "#475569",
background: alpha("#f8fafc", 0.8),
fontSize: "0.8125rem",
}}
/>
))}
</Stack>
</Box>
)}
</Box>
<PrimaryButton
onClick={onGenerateScript}
disabled={!canGenerateScript}
@@ -175,6 +123,153 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
</PrimaryButton>
</Stack>
<Box sx={{ width: "100%" }}>
{/* Main Summary */}
{research.summary && (
<Paper
elevation={0}
sx={{
p: 2.5,
mb: 3,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.06)",
borderRadius: 2,
}}
>
<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
</Typography>
<Box sx={{
lineHeight: 1.6,
fontSize: "0.9rem",
color: "#334155",
"& p": { m: 0, mb: 1 },
"& ul": { m: 0, mb: 1, pl: 2.5 },
"& li": { mb: 0.5 },
"& strong": { color: "#0f172a", fontWeight: 600 }
}}>
{renderMarkdown(research.summary)}
</Box>
</Paper>
)}
{/* Deep Insights */}
{(research.keyInsights && research.keyInsights.length > 0) ? (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<ArticleIcon sx={{ color: "#667eea" }} />
Deep Insights
</Typography>
<Stack spacing={2.5}>
{research.keyInsights.map((insight: ResearchInsight, idx: number) => (
<Paper
key={idx}
elevation={0}
sx={{
p: 2.5,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
borderRadius: 2,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
{insight.title}
</Typography>
{insight.source_indices && insight.source_indices.length > 0 && (
<Stack direction="row" spacing={0.5}>
{insight.source_indices.map(sIdx => (
<Chip
key={sIdx}
label={`S${sIdx}`}
size="small"
variant="outlined"
sx={{
height: 18,
fontSize: '0.65rem',
fontWeight: 700,
borderColor: alpha("#667eea", 0.3),
color: "#667eea",
bgcolor: alpha("#667eea", 0.05)
}}
/>
))}
</Stack>
)}
</Stack>
<Box sx={{
color: "#475569",
lineHeight: 1.7,
fontSize: "0.9rem",
"& p": { m: 0, mb: 1.5 },
"& ul": { m: 0, mb: 1.5, pl: 2 }
}}>
{renderMarkdown(insight.content)}
</Box>
</Paper>
))}
</Stack>
</Box>
) : (
/* Fallback if keyInsights is missing but we have summary paragraphs */
research.summary && research.summary.length > 500 && !research.keyInsights && (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<ArticleIcon sx={{ color: "#667eea" }} />
Additional Insights
</Typography>
<Paper
elevation={0}
sx={{
p: 2.5,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
borderRadius: 2,
}}
>
<Box sx={{
color: "#475569",
lineHeight: 1.7,
fontSize: "0.9rem",
}}>
{/* Render parts of summary that might contain insights if structured data is missing */}
{renderMarkdown(research.summary.split('\n\n').slice(1).join('\n\n'))}
</Box>
</Paper>
</Box>
)
)}
{/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && (
<Box sx={{ mt: 4, pt: 3, borderTop: "1px solid rgba(0,0,0,0.04)" }}>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Search Queries Used
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{research.searchQueries.map((query, idx) => (
<Chip
key={idx}
label={query}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(102, 126, 234, 0.15)",
color: "#94a3b8",
background: alpha("#f8fafc", 0.3),
fontSize: "0.7rem",
borderRadius: 1,
}}
/>
))}
</Stack>
</Box>
)}
</Box>
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />

View File

@@ -3,6 +3,6 @@ export { ProgressStepper } from "./ProgressStepper";
export { EstimateCard } from "./EstimateCard";
export { QuerySelection } from "./QuerySelection";
export { ResearchSummary } from "./ResearchSummary";
export { RegenerationFeedbackModal } from "./RegenerationFeedbackModal";
export { usePodcastWorkflow } from "./usePodcastWorkflow";
export * from "./utils";

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { ResearchProvider, ResearchConfig } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
import { CreateProjectPayload, Query, Research, Script, Job } from "../types";
import { CreateProjectPayload, Script } from "../types";
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
@@ -43,6 +42,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
setBudgetCap,
updateRenderJob,
initializeProject,
setBible,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
@@ -75,7 +75,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, []); // Only on mount
}, [project, currentStep]);
useEffect(() => {
if (announcement) {
@@ -85,7 +85,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
return undefined;
}, [announcement]);
const handleCreate = useCallback(async (payload: CreateProjectPayload) => {
const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => {
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
@@ -95,8 +95,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
try {
setIsAnalyzing(true);
// Upload avatar if provided, or generate presenters
let avatarUrl: string | null = null;
// Use existing avatar URL if provided (e.g. brand avatar), or upload new file
let avatarUrl: string | null = payload.avatarUrl || null;
if (payload.files.avatarFile) {
try {
setAnnouncement("Uploading presenter avatar...");
@@ -108,10 +108,46 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
}
}
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject({ ...payload, avatarUrl });
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers, avatarUrl });
// NEW FLOW: Create project first to generate/get the Podcast Bible
// 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);
const bible = dbProject?.bible || projectState.bible;
setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload, bible, feedback);
if (result.bible) {
setBible(result.bible);
} else if (dbProject?.bible) {
setBible(dbProject.bible);
}
// Update the project in database with the analysis results
try {
await podcastApi.updateProject(projectId, {
analysis: result.analysis,
estimate: result.estimate,
queries: result.queries,
selected_queries: result.queries.map(q => q.id),
avatar_url: result.avatar_url,
avatar_prompt: result.avatar_prompt,
});
} catch (error) {
console.error('Failed to update project with analysis results:', error);
}
setProject({
id: projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
avatarUrl: result.avatar_url || avatarUrl,
avatarPrompt: result.avatar_prompt || null,
avatarPersonaId: null,
});
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
@@ -188,7 +224,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally {
setIsAnalyzing(false);
}
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap]);
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap, setBible]);
const handleRunResearch = useCallback(async () => {
if (isResearching) return;
@@ -230,6 +266,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
approvedQueries,
provider: researchProvider,
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
bible: projectState.bible,
analysis: analysis,
onProgress: (message) => {
setAnnouncement(message);
},
@@ -258,7 +296,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally {
setIsResearching(false);
}
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue]);
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
const handleGenerateScript = useCallback(async () => {
if (showScriptEditor) return;
@@ -282,7 +320,25 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor]);
try {
const result = await podcastApi.generateScript({
projectId: project.id,
idea: project.idea,
research: rawResearch,
knobs: projectState.knobs,
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
});
setScriptData(result);
} catch (error) {
announceError(setAnnouncement, error);
}
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible])
const handleProceedToRendering = useCallback((script: Script) => {
setScriptData(script);
@@ -316,13 +372,30 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (research) return 1;
if (analysis) return 0;
if (currentStep === 'research' || research) return 1;
if (currentStep === 'analysis' || analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, research, analysis]);
}, [showRenderQueue, showScriptEditor, currentStep, research, analysis]);
const canGenerateScript = Boolean(project && research && rawResearch);
const handleRegenerate = useCallback(async (feedback?: string) => {
if (!project) return;
// Prepare the payload from existing project state
const payload: CreateProjectPayload = {
ideaOrUrl: project.idea,
duration: project.duration,
speakers: project.speakers,
knobs: projectState.knobs,
budgetCap: projectState.budgetCap,
avatarUrl: project.avatarUrl,
files: {} // No new files for regeneration
};
await handleCreate(payload, feedback);
}, [project, projectState.knobs, projectState.budgetCap, handleCreate]);
return {
// State
isAnalyzing,
@@ -336,6 +409,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
canGenerateScript,
// Handlers
handleCreate,
handleRegenerate,
handleRunResearch,
handleGenerateScript,
handleProceedToRendering,

View File

@@ -20,8 +20,10 @@ interface RenderQueueProps {
script: Script;
knobs: Knobs;
jobs: Job[];
bible?: any | null;
budgetCap?: number;
avatarImageUrl?: string | null;
analysis?: any | null; // Add analysis prop
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onUpdateScript?: (script: Script) => void;
onBack: () => void;
@@ -33,8 +35,10 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
script,
knobs,
jobs,
bible,
budgetCap,
avatarImageUrl,
analysis,
onUpdateJob,
onUpdateScript,
onBack,
@@ -57,6 +61,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
jobs,
knobs,
projectId,
bible,
budgetCap,
avatarImageUrl,
onUpdateJob,
@@ -167,41 +172,124 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
</Alert>
)}
{/* Info Alert */}
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Audio Generation:</strong> Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode.
</Typography>
</Alert>
{/* Compact Status Dashboard */}
<Paper
elevation={0}
sx={{
mb: 3,
p: 2,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.02)",
}}
>
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap" useFlexGap>
{/* Status Chips */}
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: alpha("#6366f1", 0.08),
color: "#4f46e5",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: alpha("#6366f1", 0.2),
}}
>
<Typography variant="caption" fontWeight={700} sx={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
Scenes
</Typography>
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.length}
</Typography>
</Box>
{/* Summary Stats */}
<SummaryStats jobs={jobs} scenes={script.scenes} />
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: script.scenes.every(s => s.audioUrl)
? alpha("#10b981", 0.1)
: alpha("#f59e0b", 0.1),
color: script.scenes.every(s => s.audioUrl) ? "#059669" : "#d97706",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: script.scenes.every(s => s.audioUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
}}
>
<Typography variant="caption" fontWeight={700}>
Audio
</Typography>
{script.scenes.every(s => s.audioUrl) ? (
<CheckCircleIcon sx={{ fontSize: 18 }} />
) : (
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.filter(s => s.audioUrl).length}/{script.scenes.length}
</Typography>
)}
</Box>
{/* Empty State */}
{jobs.length === 0 && script.scenes.length === 0 && (
<Paper
sx={{
p: 4,
textAlign: "center",
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "2px dashed rgba(102, 126, 234, 0.3)",
borderRadius: 2,
}}
>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, mb: 1 }}>
No scenes to render
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mb: 3 }}>
Go back to the script editor to generate and approve scenes first.
</Typography>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script Editor
</SecondaryButton>
</Paper>
)}
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: script.scenes.every(s => s.imageUrl)
? alpha("#10b981", 0.1)
: alpha("#f59e0b", 0.1),
color: script.scenes.every(s => s.imageUrl) ? "#059669" : "#d97706",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: script.scenes.every(s => s.imageUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
}}
>
<Typography variant="caption" fontWeight={700}>
Images
</Typography>
{script.scenes.every(s => s.imageUrl) ? (
<CheckCircleIcon sx={{ fontSize: 18 }} />
) : (
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.filter(s => s.imageUrl).length}/{script.scenes.length}
</Typography>
)}
</Box>
</Box>
{/* Guidance Panel */}
{script.scenes.length > 0 && <GuidancePanel scenes={script.scenes} />}
{/* Dynamic Guidance Message */}
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 500, display: "flex", alignItems: "center", gap: 1 }}>
<Box component="span" sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: allVideosReady ? "#10b981" : "#3b82f6",
display: "inline-block"
}} />
{allVideosReady
? "All assets ready. You can combine videos below."
: !script.scenes.every(s => s.audioUrl)
? "Generate audio for all scenes to proceed."
: !script.scenes.every(s => s.imageUrl)
? "Generate images for video backgrounds."
: "Ready to generate scene videos."}
</Typography>
</Stack>
</Paper>
{/* Scene Cards */}
<Stack spacing={2}>
@@ -216,6 +304,8 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
generatingImage={generatingImage}
isBusy={isBusy}
avatarImageUrl={avatarImageUrl}
bible={bible}
analysis={analysis}
onRender={runRender}
onImageGenerate={runImageGeneration}
onVideoGenerate={(sceneId, settings) => runVideoRender(sceneId, settings)}

View File

@@ -1,16 +1,17 @@
import React from "react";
import { Stack } from "@mui/material";
import { Stack, alpha } from "@mui/material";
import {
VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon,
Image as ImageIcon,
Videocam as VideocamIcon,
Download as DownloadIcon,
Share as ShareIcon,
PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon,
} from "@mui/icons-material";
import { Scene, Job } from "../types";
import { PrimaryButton, SecondaryButton } from "../ui";
import { Typography } from "@mui/material"; // Import Typography
interface SceneActionButtonsProps {
scene: Scene;
@@ -76,7 +77,26 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
);
}
// Failed - show retry
// Video generation failed - show specific retry for video
if (job?.status === "failed" && !needsAudio && hasAudio) {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Typography variant="caption" color="error" sx={{ alignSelf: "center", mr: 1 }}>
Video Generation Failed
</Typography>
<SecondaryButton
onClick={() => onVideoRender(scene.id)}
startIcon={<RefreshIcon />}
tooltip="Retry video generation"
sx={{ borderColor: "error.main", color: "error.main" }}
>
Retry Video
</SecondaryButton>
</Stack>
);
}
// Failed (Audio) - show retry
if (job?.status === "failed") {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
@@ -85,7 +105,7 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
startIcon={<RefreshIcon />}
tooltip="Retry audio generation"
>
Retry
Retry Audio
</SecondaryButton>
</Stack>
);
@@ -97,40 +117,49 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
return (
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
{/* Generate Image */}
{/* Generate/Regenerate Image - ALWAYS visible if we have audio */}
<PrimaryButton
onClick={() => onImageGenerate(scene.id)}
disabled={isGeneratingImage || hasImage}
disabled={isGeneratingImage}
loading={isGeneratingImage}
startIcon={<ImageIcon />}
tooltip={
hasImage
? "Image already generated for this scene"
: isGeneratingImage
isGeneratingImage
? "Generating image..."
: hasImage
? "Regenerate image for this scene"
: "Generate image for video (optional)"
}
sx={{ minWidth: 160 }}
sx={{
minWidth: 160,
// Use secondary style if image exists (to de-emphasize), primary if needed
background: hasImage ? alpha("#667eea", 0.1) : undefined,
color: hasImage ? "#667eea" : undefined,
border: hasImage ? "1px solid rgba(102,126,234,0.3)" : undefined,
"&:hover": {
background: hasImage ? alpha("#667eea", 0.2) : undefined,
}
}}
>
{isGeneratingImage ? "Generating..." : hasImage ? "Image Ready" : "Generate Image"}
{isGeneratingImage ? "Generating..." : hasImage ? "Regenerate Image" : "Generate Image"}
</PrimaryButton>
{/* Generate Video */}
{/* Generate Video - ALWAYS visible if we have audio */}
<PrimaryButton
onClick={() => {
onVideoRender(scene.id);
}}
disabled={isBusy || videoInProgress || !hasImage || hasVideo}
disabled={isBusy || videoInProgress || !hasImage}
startIcon={<VideocamIcon />}
tooltip={
hasVideo
? "Video already generated"
: !hasImage
!hasImage
? "Generate an image first to create video"
: videoInProgress
? "A video generation is already running. Please wait..."
: isBusy
? "Another operation in progress"
: hasVideo
? "Regenerate video"
: "Generate video for this scene"
}
sx={{ minWidth: 180 }}
@@ -138,7 +167,7 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
{videoInProgress && isCurrentVideo
? "Generating Video..."
: hasVideo
? "Video Ready"
? "Regenerate Video"
: "Generate Video"}
</PrimaryButton>
@@ -154,36 +183,48 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
)}
{/* Download Audio */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onDownloadAudio(audioUrl, scene.title);
}}
startIcon={<DownloadIcon />}
tooltip={hasAudio ? "Download this scene's audio file" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Download Audio
</SecondaryButton>
{hasAudio && audioUrl && (
<PrimaryButton
onClick={() => onDownloadAudio(audioUrl, scene.title)}
startIcon={<DownloadIcon />}
tooltip="Download audio file"
sx={{
minWidth: 40,
width: 40,
padding: 0,
background: alpha("#64748b", 0.1),
color: "#64748b",
border: "1px solid rgba(100, 116, 139, 0.2)",
"&:hover": {
background: alpha("#64748b", 0.2),
},
}}
>
{/* Icon only */}
</PrimaryButton>
)}
{/* Share */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onShare(audioUrl, scene.title);
}}
startIcon={<ShareIcon />}
tooltip={hasAudio ? "Share this scene's audio" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Share
</SecondaryButton>
{hasAudio && audioUrl && (
<PrimaryButton
onClick={() => onShare(audioUrl, scene.title)}
startIcon={<ShareIcon />}
tooltip="Share audio link"
sx={{
minWidth: 40,
width: 40,
padding: 0,
background: alpha("#64748b", 0.1),
color: "#64748b",
border: "1px solid rgba(100, 116, 139, 0.2)",
"&:hover": {
background: alpha("#64748b", 0.2),
},
}}
>
{/* Icon only */}
</PrimaryButton>
)}
</Stack>
);
};

View File

@@ -1,11 +1,13 @@
import React, { useState, useEffect } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha } from "@mui/material";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha, Modal, IconButton } from "@mui/material";
import {
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Videocam as VideocamIcon,
Close as CloseIcon,
ZoomIn as ZoomInIcon,
} from "@mui/icons-material";
import { Scene, Job, VideoGenerationSettings } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
@@ -22,6 +24,8 @@ interface SceneCardProps {
generatingImage: string | null;
isBusy: boolean;
avatarImageUrl?: string | null;
bible?: any;
analysis?: any;
onRender: (sceneId: string, mode: "preview" | "full") => void;
onImageGenerate: (sceneId: string) => void;
onVideoGenerate: (sceneId: string, settings: VideoGenerationSettings) => void;
@@ -75,6 +79,8 @@ export const SceneCard: React.FC<SceneCardProps> = ({
generatingImage,
isBusy,
avatarImageUrl,
bible,
analysis,
onRender,
onImageGenerate,
onVideoGenerate,
@@ -96,6 +102,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
const [showVideoModal, setShowVideoModal] = useState(false);
const [showImageModal, setShowImageModal] = useState(false);
const [initialVideoPrompt, setInitialVideoPrompt] = useState<string>("");
// Prepare a simple default prompt based on the scene title/description
@@ -261,96 +268,151 @@ export const SceneCard: React.FC<SceneCardProps> = ({
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
{/* Header */}
<Stack direction="row" spacing={2} alignItems="flex-start">
<Stack direction="row" spacing={2} alignItems="center">
{/* Visual Avatar */}
<Paper
sx={{
width: 56,
height: 56,
width: 48,
height: 48,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha("#667eea", 0.2),
border: "1px solid rgba(102,126,234,0.3)",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "#ffffff",
fontWeight: 700,
fontSize: "1.2rem",
fontSize: "1.1rem",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
}}
>
{initials}
</Paper>
{/* Title and Metadata */}
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 0.5, color: "#0f172a", fontWeight: 600 }}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${scene.id.slice(-4)}`} size="small" variant="outlined" />
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1.05rem" }}>
{scene.title}
</Typography>
{/* Quick Downloads */}
<Stack direction="row" spacing={1.5} alignItems="center">
{job?.finalUrl && (
<Box
component="a"
href={job.finalUrl}
target="_blank"
rel="noopener noreferrer"
sx={{
color: "#64748b",
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: 0.5,
fontSize: "0.75rem",
fontWeight: 600,
"&:hover": { color: "#6366f1" }
}}
>
<OpenInNewIcon sx={{ fontSize: 14 }} />
Audio
</Box>
)}
{hasVideo && videoBlobUrl && (
<Box
component="a"
href={videoBlobUrl}
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
sx={{
color: "#64748b",
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: 0.5,
fontSize: "0.75rem",
fontWeight: 600,
"&:hover": { color: "#6366f1" }
}}
>
<VideocamIcon sx={{ fontSize: 14 }} />
Video
</Box>
)}
</Stack>
</Stack>
{/* Compact Metadata Row */}
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }} useFlexGap>
{/* Scene ID */}
<Chip
label={`Scene ${scene.id.slice(-4)}`}
size="small"
sx={{
height: 20,
fontSize: "0.7rem",
background: alpha("#64748b", 0.08),
color: "#64748b",
fontWeight: 600
}}
/>
{/* Audio Status */}
<Chip
label={hasAudio ? "Audio Ready" : "Needs Audio"}
size="small"
sx={{
height: 20,
fontSize: "0.7rem",
background: hasAudio ? alpha("#10b981", 0.1) : alpha("#f59e0b", 0.1),
color: hasAudio ? "#059669" : "#d97706",
fontWeight: 700,
border: "1px solid",
borderColor: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
}}
/>
{/* Cost */}
{job?.cost != null && (
<Chip
label={`$${job.cost.toFixed(2)}`}
size="small"
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
title="Generation cost"
sx={{
height: 20,
fontSize: "0.7rem",
background: alpha("#6366f1", 0.08),
color: "#6366f1",
fontWeight: 600
}}
/>
)}
{job?.fileSize && (
<Typography variant="caption" color="text.secondary">
{(job.fileSize / 1024).toFixed(1)} KB
</Typography>
)}
{!job && (
{/* Job Status (if active/failed) */}
{job && job.status !== "idle" && (
<Chip
label={hasAudio ? "Audio Ready" : "Needs Audio"}
icon={getStatusIcon(status)}
label={status.charAt(0).toUpperCase() + status.slice(1)}
size="small"
color={hasAudio ? "success" : "warning"}
sx={{
background: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: hasAudio ? "#059669" : "#d97706",
fontWeight: 600,
height: 20,
fontSize: "0.7rem",
background: status === "completed" ? alpha("#10b981", 0.1) : status === "failed" ? alpha("#ef4444", 0.1) : alpha("#3b82f6", 0.1),
color: status === "completed" ? "#059669" : status === "failed" ? "#dc2626" : "#2563eb",
fontWeight: 700,
"& .MuiChip-icon": { fontSize: 14, color: "inherit" }
}}
/>
)}
</Stack>
{job?.finalUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={job.finalUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Final Audio</Typography>
</Box>
</Box>
)}
{hasVideo && videoBlobUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={videoBlobUrl}
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<VideocamIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Video</Typography>
</Box>
</Box>
)}
</Box>
{job && (
<Chip
icon={getStatusIcon(status)}
label={status.charAt(0).toUpperCase() + status.slice(1)}
color={getStatusColor(status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
)}
</Stack>
{/* Audio Player - Now directly in header section (visual integration) */}
{hasAudio && audioUrl && (
<Box sx={{ width: "100%", mt: 1 }}>
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
</Box>
)}
{/* Progress Bar */}
{job && job.status !== "idle" && job.status !== "completed" && (
<Box>
@@ -379,20 +441,6 @@ export const SceneCard: React.FC<SceneCardProps> = ({
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
{/* Success Alert for Pre-generated Audio */}
{hasAudio && !job && (
<Alert severity="success" sx={{ width: "100%", background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)" }}>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
Audio already generated in Script Editor. Ready to use!
</Typography>
</Alert>
)}
{/* Audio Player */}
{hasAudio && audioUrl && (
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
)}
{/* Video Preview - Show video if available, otherwise show image */}
{hasVideo && videoBlobUrl ? (
<Box
@@ -415,7 +463,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
height: "auto",
display: "block",
maxHeight: 420,
objectFit: "cover",
objectFit: "contain",
backgroundColor: "black",
}}
onError={(e) => {
@@ -443,34 +491,62 @@ export const SceneCard: React.FC<SceneCardProps> = ({
VIDEO
</Box>
</Box>
) : hasImage && (imageBlobUrl || imageUrl) ? (
<Box
sx={{
width: "100%",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
}}
>
) : hasImage && (imageBlobUrl || (imageUrl && !imageUrl.includes('/api/'))) ? (
<Box sx={{ position: "relative", width: "100%" }}>
<Box
component="img"
src={imageBlobUrl || imageUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "cover",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
cursor: "pointer",
"&:hover .zoom-icon": {
opacity: 1,
}
}}
onError={(e) => {
console.error("[SceneCard] Image failed to load:", {
src: e.currentTarget.src,
imageUrl,
});
}}
/>
onClick={() => setShowImageModal(true)}
>
<Box
component="img"
src={imageBlobUrl || imageUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "contain",
background: "#000",
}}
onError={(e) => {
console.error("[SceneCard] Image failed to load:", {
src: e.currentTarget.src,
imageUrl,
});
}}
/>
<Box
className="zoom-icon"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "rgba(0,0,0,0.6)",
color: "white",
borderRadius: "50%",
p: 1.5,
opacity: 0,
transition: "opacity 0.2s",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ZoomInIcon sx={{ fontSize: 32 }} />
</Box>
</Box>
</Box>
) : null}
@@ -505,6 +581,9 @@ export const SceneCard: React.FC<SceneCardProps> = ({
initialPrompt={initialVideoPrompt}
initialResolution="480p"
initialSeed={-1}
sceneTitle={scene.title}
bible={bible}
analysis={analysis}
/>
</Stack>
</GlassyCard>

View File

@@ -26,6 +26,10 @@ interface VideoRegenerateModalProps {
initialPrompt: string;
initialResolution?: "480p" | "720p";
initialSeed?: number | null;
// Add context props
sceneTitle?: string;
bible?: any;
analysis?: any;
}
export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
@@ -35,17 +39,45 @@ export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
initialPrompt,
initialResolution = "480p",
initialSeed = -1,
sceneTitle,
bible,
analysis,
}) => {
// Use a more intelligent default prompt based on context if available
const [prompt, setPrompt] = useState(initialPrompt);
// Update prompt when context changes or modal opens
useEffect(() => {
if (open) {
let smartPrompt = initialPrompt;
// 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.`;
}
}
setPrompt(smartPrompt);
}
}, [open, initialPrompt, sceneTitle, bible, analysis]);
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
const [maskImageUrl, setMaskImageUrl] = useState<string>("");
useEffect(() => {
setPrompt(initialPrompt);
setResolution(initialResolution);
}, [initialResolution, initialPrompt]);
const handleGenerate = () => {
const parsedSeed = seed.trim() === "" ? undefined : Number.isNaN(Number(seed)) ? undefined : Number(seed);
const settings: VideoGenerationSettings = {

View File

@@ -7,6 +7,7 @@ interface UseRenderQueueProps {
jobs: Job[];
knobs: Knobs;
projectId: string;
bible?: any | null;
budgetCap?: number;
avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
@@ -21,6 +22,7 @@ export const useRenderQueue = ({
jobs,
knobs,
projectId,
bible,
budgetCap,
avatarImageUrl,
onUpdateJob,
@@ -441,6 +443,7 @@ export const useRenderQueue = ({
sceneTitle: scene.title,
sceneContent: sceneContent,
baseAvatarUrl: avatarImageUrl || undefined, // Use base avatar if available
bible: bible,
width: 1024,
height: 1024,
});
@@ -544,6 +547,7 @@ export const useRenderQueue = ({
sceneTitle: scene.title,
audioUrl,
avatarImageUrl: sceneImageUrl,
bible: bible,
resolution: targetResolution,
prompt: settings?.prompt || undefined,
seed: settings?.seed ?? -1,

View File

@@ -23,6 +23,8 @@ interface ScriptEditorProps {
onProceedToRendering: (script: Script) => void;
onError: (message: string) => void;
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
analysis?: any;
outline?: any;
}
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
@@ -39,6 +41,8 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
onProceedToRendering,
onError,
avatarUrl,
analysis,
outline,
}) => {
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
@@ -89,6 +93,8 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
knobs,
speakers,
durationMinutes,
analysis,
outline,
})
.then((res) => {
if (mounted) {
@@ -106,7 +112,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, analysis, outline, emitScriptChange, onError, script]);
const updateScene = (updated: Scene) => {
// Use functional update to ensure we're working with latest state

View File

@@ -20,10 +20,20 @@ export type Fact = {
url: string;
date: string;
confidence: number;
image?: string;
author?: string;
highlights?: string[];
};
export type ResearchInsight = {
title: string;
content: string;
source_indices: number[];
};
export type Research = {
summary: string;
keyInsights: ResearchInsight[];
factCards: Fact[];
mappedAngles: {
title: string;
@@ -94,6 +104,7 @@ export type PodcastAnalysis = {
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
suggestedKnobs: Knobs;
titleSuggestions: string[];
research_queries?: { query: string; rationale: string }[];
exaSuggestedConfig?: {
exa_search_type?: "auto" | "keyword" | "neural";
exa_category?: string;
@@ -113,6 +124,37 @@ export type PodcastEstimate = {
total: number;
};
export type HostPersona = {
name: string;
background: string;
expertise_level: string;
personality_traits: string[];
vocal_style: string;
catchphrases: string[];
};
export type AudienceDNA = {
expertise_level: string;
interests: string[];
pain_points: string[];
demographics?: string;
};
export type BrandDNA = {
industry: string;
tone: string;
communication_style: string;
key_messages: string[];
competitor_context?: string;
};
export type PodcastBible = {
project_id?: string;
host: HostPersona;
audience: AudienceDNA;
brand: BrandDNA;
};
export type CreateProjectPayload = {
ideaOrUrl: string;
speakers: number;
@@ -128,6 +170,9 @@ export type CreateProjectResult = {
analysis: PodcastAnalysis;
estimate: PodcastEstimate;
queries: Query[];
bible?: PodcastBible;
avatar_url?: string | null;
avatar_prompt?: string | null;
};
export type RenderJobResult = {